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 +7 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +169 -0
- data/Rakefile +10 -0
- data/exe/wtr +4 -0
- data/lib/wtr/cli.rb +110 -0
- data/lib/wtr/commands/cd.rb +39 -0
- data/lib/wtr/commands/ls.rb +56 -0
- data/lib/wtr/commands/new.rb +81 -0
- data/lib/wtr/commands/rm.rb +44 -0
- data/lib/wtr/completion.rb +90 -0
- data/lib/wtr/config.rb +56 -0
- data/lib/wtr/log.rb +15 -0
- data/lib/wtr/result.rb +35 -0
- data/lib/wtr/version.rb +5 -0
- data/lib/wtr/worktrees.rb +190 -0
- data/lib/wtr.rb +13 -0
- data/sig/wtr.rbs +3 -0
- metadata +65 -0
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
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -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
data/exe/wtr
ADDED
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
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
|
data/lib/wtr/version.rb
ADDED
|
@@ -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
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: []
|