gitflash 0.7.0.beta1 → 0.7.0.beta3
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 +4 -4
- data/CHANGELOG.md +59 -0
- data/README.md +17 -13
- data/command_descriptions.yml +25 -7
- data/lib/gitflash/atomic_file.rb +40 -0
- data/lib/gitflash/cherry_pick/log_parser.rb +63 -0
- data/lib/gitflash/cherry_pick.rb +10 -18
- data/lib/gitflash/cli.rb +28 -5
- data/lib/gitflash/command_runner.rb +12 -1
- data/lib/gitflash/commands/base.rb +3 -0
- data/lib/gitflash/commands/clean.rb +29 -1
- data/lib/gitflash/commands/gc.rb +19 -4
- data/lib/gitflash/commands/hook_install.rb +79 -12
- data/lib/gitflash/commands/mark.rb +9 -3
- data/lib/gitflash/commands/pick.rb +13 -1
- data/lib/gitflash/commands/pick_control.rb +1 -1
- data/lib/gitflash/commands/removal_report.rb +8 -3
- data/lib/gitflash/commands/snapshot_create.rb +6 -4
- data/lib/gitflash/commands/undo.rb +24 -11
- data/lib/gitflash/commands/worktree_add.rb +6 -4
- data/lib/gitflash/commands/worktree_command.rb +17 -3
- data/lib/gitflash/commands/worktree_list.rb +3 -3
- data/lib/gitflash/commands/worktree_lock.rb +7 -3
- data/lib/gitflash/commands/worktree_move.rb +9 -4
- data/lib/gitflash/commands/worktree_prune.rb +1 -1
- data/lib/gitflash/commands/worktree_remove.rb +23 -7
- data/lib/gitflash/config.rb +69 -16
- data/lib/gitflash/config_keys.rb +64 -4
- data/lib/gitflash/confirmation_required.rb +6 -2
- data/lib/gitflash/git/bash_command.rb +5 -2
- data/lib/gitflash/hook/branch_marker.rb +109 -6
- data/lib/gitflash/hook/claude.rb +45 -24
- data/lib/gitflash/hook/command_parser.rb +100 -59
- data/lib/gitflash/hook/command_words.rb +73 -0
- data/lib/gitflash/hook/failure.rb +29 -0
- data/lib/gitflash/hook/git_invocation.rb +119 -0
- data/lib/gitflash/hook/history_rules.rb +81 -0
- data/lib/gitflash/hook/losses.rb +51 -0
- data/lib/gitflash/hook/messages.rb +64 -0
- data/lib/gitflash/hook/paths.rb +47 -0
- data/lib/gitflash/hook/rules.rb +36 -12
- data/lib/gitflash/hook/settings.rb +19 -2
- data/lib/gitflash/hook/shell_quotes.rb +64 -0
- data/lib/gitflash/hook/shell_text.rb +101 -0
- data/lib/gitflash/hook/wrappers.rb +67 -0
- data/lib/gitflash/hook_cli.rb +34 -4
- data/lib/gitflash/protection.rb +12 -4
- data/lib/gitflash/repo.rb +3 -2
- data/lib/gitflash/snapshot.rb +9 -2
- data/lib/gitflash/snapshot_check.rb +60 -0
- data/lib/gitflash/snapshots.rb +26 -3
- data/lib/gitflash/state_capture.rb +26 -24
- data/lib/gitflash/terminal_text.rb +9 -0
- data/lib/gitflash/ui.rb +32 -4
- data/lib/gitflash/untracked_files.rb +19 -0
- data/lib/gitflash/usage_report.rb +66 -0
- data/lib/gitflash/version.rb +1 -1
- data/lib/gitflash/worktree.rb +4 -4
- data/schema/v1.json +39 -2
- metadata +16 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'fileutils'
|
|
4
|
+
require 'shellwords'
|
|
4
5
|
|
|
5
6
|
module Gitflash
|
|
6
7
|
module Commands
|
|
@@ -11,10 +12,14 @@ module Gitflash
|
|
|
11
12
|
SCOPES = Hook::Settings::SCOPES
|
|
12
13
|
MARKING_COMMAND = 'gitflash hook claude'
|
|
13
14
|
|
|
15
|
+
# From the weakest protection to the strongest
|
|
16
|
+
STRENGTH = %w[snapshot ask deny].freeze
|
|
17
|
+
|
|
14
18
|
def call
|
|
15
19
|
plan = build_plan
|
|
16
20
|
data = settings.read(plan[:file])
|
|
17
21
|
entry = settings.entry(data)
|
|
22
|
+
add_mode(plan, entry)
|
|
18
23
|
marking = settings.entry(data, Hook::Claude::POST_EVENT)
|
|
19
24
|
return already_installed(plan) if up_to_date?(entry, marking, plan)
|
|
20
25
|
return planned(plan, "Would add the gitflash hook to #{plan[:file]}") if ui.dry_run?
|
|
@@ -30,8 +35,59 @@ module Gitflash
|
|
|
30
35
|
|
|
31
36
|
def build_plan
|
|
32
37
|
scope = choice(:scope, SCOPES, 'local')
|
|
33
|
-
|
|
34
|
-
|
|
38
|
+
file = settings.path(scope)
|
|
39
|
+
refuse_symlink!(file) unless scope == 'user'
|
|
40
|
+
{ scope: scope, file: file, marking_command: MARKING_COMMAND,
|
|
41
|
+
applies_to: settings.applies_to(scope) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Without --mode the installed mode is kept, so running install again to add the
|
|
45
|
+
# PostToolUse hook never turns ask or deny off. A weaker --mode needs --downgrade.
|
|
46
|
+
def add_mode(plan, entry)
|
|
47
|
+
previous = entry && installed_mode(entry['command'])
|
|
48
|
+
mode = options[:mode] ? choice(:mode, Hook::Claude::MODES, 'snapshot') : previous
|
|
49
|
+
mode ||= 'snapshot'
|
|
50
|
+
refuse_downgrade!(previous, mode) if previous
|
|
51
|
+
plan[:mode] = mode
|
|
52
|
+
plan[:previous_mode] = previous if previous && previous != mode
|
|
53
|
+
plan[:command] = hook_command(mode)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The mode the installed command runs in, read the way a shell splits it: `snapshot` when
|
|
57
|
+
# it has no --mode; a --mode gitflash does not know is refused, never read as snapshot,
|
|
58
|
+
# so a reinstall cannot quietly weaken it
|
|
59
|
+
def installed_mode(command)
|
|
60
|
+
words = Shellwords.split(command.to_s)
|
|
61
|
+
index = words.index { |word| word == '--mode' || word.start_with?('--mode=') }
|
|
62
|
+
return 'snapshot' unless index
|
|
63
|
+
|
|
64
|
+
mode = words[index] == '--mode' ? words[index + 1] : words[index].delete_prefix('--mode=')
|
|
65
|
+
return mode if Hook::Claude::MODES.include?(mode)
|
|
66
|
+
|
|
67
|
+
raise Error.new("The installed hook command `#{command}` has a mode gitflash cannot " \
|
|
68
|
+
'read; fix or remove it, then install again', code: 'invalid_settings')
|
|
69
|
+
rescue ArgumentError
|
|
70
|
+
raise Error.new("The installed hook command `#{command}` cannot be parsed",
|
|
71
|
+
code: 'invalid_settings')
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def refuse_downgrade!(previous, mode)
|
|
75
|
+
return if options[:downgrade] || STRENGTH.index(mode).to_i >= STRENGTH.index(previous).to_i
|
|
76
|
+
|
|
77
|
+
usage_error!('invalid_options',
|
|
78
|
+
"The installed hook runs in #{previous} mode; --mode #{mode} would protect " \
|
|
79
|
+
'less. Pass --downgrade to change it anyway.')
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The repository decides what .claude holds, and a link there could point at the user's own
|
|
83
|
+
# settings; writing through it would change a scope other than the one asked for. The
|
|
84
|
+
# user scope may be a link (dotfiles) because the user owns it.
|
|
85
|
+
def refuse_symlink!(file)
|
|
86
|
+
linked = [file, File.dirname(file)].find { |path| File.symlink?(path) }
|
|
87
|
+
return unless linked
|
|
88
|
+
|
|
89
|
+
raise Error.new("#{linked} is a symbolic link; gitflash will not write settings through it",
|
|
90
|
+
code: 'invalid_settings')
|
|
35
91
|
end
|
|
36
92
|
|
|
37
93
|
def settings
|
|
@@ -41,10 +97,9 @@ module Gitflash
|
|
|
41
97
|
def install(data, entry, marking, plan)
|
|
42
98
|
entry ? entry['command'] = plan[:command] : add_entry(data, 'PreToolUse', plan[:command])
|
|
43
99
|
add_entry(data, Hook::Claude::POST_EVENT, MARKING_COMMAND) unless marking
|
|
44
|
-
|
|
45
|
-
File.write(plan[:file], "#{JSON.pretty_generate(data)}\n")
|
|
100
|
+
write_atomically(plan[:file], "#{JSON.pretty_generate(data)}\n")
|
|
46
101
|
ui.report(status: 'done', plan: plan, result: plan,
|
|
47
|
-
text: "Installed the gitflash hook in #{plan[:file]}.\n" \
|
|
102
|
+
text: "Installed the gitflash hook in #{plan[:file]} (#{mode_text(plan)}).\n" \
|
|
48
103
|
"It applies to #{plan[:applies_to]}, including running ones.")
|
|
49
104
|
end
|
|
50
105
|
|
|
@@ -55,22 +110,34 @@ module Gitflash
|
|
|
55
110
|
usage_error!('invalid_options', "Unknown #{name} '#{value}' (use #{allowed.join(', ')})")
|
|
56
111
|
end
|
|
57
112
|
|
|
58
|
-
def hook_command
|
|
59
|
-
mode = choice(:mode, Hook::Claude::MODES, 'snapshot')
|
|
113
|
+
def hook_command(mode)
|
|
60
114
|
mode == 'snapshot' ? 'gitflash hook claude' : "gitflash hook claude --mode #{mode}"
|
|
61
115
|
end
|
|
62
116
|
|
|
117
|
+
def mode_text(plan)
|
|
118
|
+
changed = plan[:previous_mode] ? ", was #{plan[:previous_mode]}" : ''
|
|
119
|
+
"#{plan[:mode]} mode#{changed}"
|
|
120
|
+
end
|
|
121
|
+
|
|
63
122
|
def already_installed(plan)
|
|
64
123
|
ui.report(status: 'noop', plan: plan, result: plan,
|
|
65
124
|
text: "Already installed in #{plan[:file]}.\nIt applies to #{plan[:applies_to]}.")
|
|
66
125
|
end
|
|
67
126
|
|
|
127
|
+
# Claude Code ignores a settings file it cannot parse, and with it the user's own
|
|
128
|
+
# permission rules, so the file is replaced in one step (AtomicFile)
|
|
129
|
+
def write_atomically(path, text)
|
|
130
|
+
AtomicFile.write(path, text)
|
|
131
|
+
rescue SystemCallError, IOError => e
|
|
132
|
+
raise Error.new("Could not write #{path}: #{e.message}", code: 'invalid_settings')
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Settings#read has checked that `hooks` and each of its events have the shape Claude Code
|
|
136
|
+
# reads, so a missing event is the only one to create
|
|
68
137
|
def add_entry(data, event, command)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
'hooks' => [{ 'type' => 'command', 'command' => command, 'timeout' => 30 }]
|
|
73
|
-
}
|
|
138
|
+
groups = ((data['hooks'] ||= {})[event] ||= [])
|
|
139
|
+
hook = { 'type' => 'command', 'command' => command, 'timeout' => 30 }
|
|
140
|
+
groups << { 'matcher' => 'Bash', 'hooks' => [hook] }
|
|
74
141
|
end
|
|
75
142
|
end
|
|
76
143
|
end
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module Gitflash
|
|
4
4
|
module Commands
|
|
5
|
-
# Marks branches as agent or human work (`--owner
|
|
6
|
-
#
|
|
5
|
+
# Marks branches as agent or human work (`--owner`) or removes the mark (`--clear`), so
|
|
6
|
+
# `gitflash clean --agent` knows which branches an agent created. Without --owner a person
|
|
7
|
+
# in a terminal marks the branch as theirs (human) and an agent or script as agent work:
|
|
8
|
+
# `agent` is the side `clean --agent` deletes, so a person never claims it by accident.
|
|
7
9
|
# Without branch names the current branch is marked. Needs no confirmation: only a git
|
|
8
10
|
# config key changes, and `--clear` or another `mark` reverses it.
|
|
9
11
|
class Mark < Base
|
|
@@ -32,12 +34,16 @@ module Gitflash
|
|
|
32
34
|
usage_error!('invalid_options', 'Use either --owner or --clear, not both') if given
|
|
33
35
|
return nil
|
|
34
36
|
end
|
|
35
|
-
return
|
|
37
|
+
return default_owner if given.nil?
|
|
36
38
|
return given if Ownership::OWNERS.include?(given)
|
|
37
39
|
|
|
38
40
|
usage_error!('invalid_options', "Unknown owner '#{given}' (use agent or human)")
|
|
39
41
|
end
|
|
40
42
|
|
|
43
|
+
def default_owner
|
|
44
|
+
$stdin.tty? ? 'human' : 'agent'
|
|
45
|
+
end
|
|
46
|
+
|
|
41
47
|
def verb
|
|
42
48
|
options[:clear] ? 'clear the mark of' : "mark as #{owner}"
|
|
43
49
|
end
|
|
@@ -18,8 +18,10 @@ module Gitflash
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def call(source = nil, *shas)
|
|
21
|
-
usage_error!('input_required', 'Pass the source branch: gitflash pick SOURCE') unless source
|
|
22
21
|
check_idle!
|
|
22
|
+
source ||= pick_source
|
|
23
|
+
return 0 if source.nil?
|
|
24
|
+
|
|
23
25
|
candidates = cherry_pick.candidates(source_ref(source))
|
|
24
26
|
return list(source, candidates) if options[:list]
|
|
25
27
|
|
|
@@ -31,6 +33,16 @@ module Gitflash
|
|
|
31
33
|
|
|
32
34
|
private
|
|
33
35
|
|
|
36
|
+
# The branch picked from a menu of the other local branches (as `checkout` does), nil after
|
|
37
|
+
# telling the user there is no other branch
|
|
38
|
+
def pick_source
|
|
39
|
+
require_interactive!('Pass the source branch: gitflash pick SOURCE')
|
|
40
|
+
branches = repo.branches
|
|
41
|
+
return nothing_chosen('You only have one branch!') if branches.size < 2
|
|
42
|
+
|
|
43
|
+
ui.select('Select the branch to pick commits from', branches.reject(&:current?).map(&:name))
|
|
44
|
+
end
|
|
45
|
+
|
|
34
46
|
def check_idle!
|
|
35
47
|
return unless cherry_pick.in_progress?
|
|
36
48
|
|
|
@@ -15,8 +15,8 @@ module Gitflash
|
|
|
15
15
|
usage_error!('invalid_usage', 'No cherry-pick in progress') unless cherry_pick.in_progress?
|
|
16
16
|
|
|
17
17
|
plan = build_plan(action)
|
|
18
|
-
return perform(action, plan, nil) if action == 'continue'
|
|
19
18
|
return planned(plan, "Would #{action} the cherry-pick in progress") if ui.dry_run?
|
|
19
|
+
return perform(action, plan, nil) if action == 'continue'
|
|
20
20
|
return cancelled(plan) unless ui.confirm?(summary(action), plan: plan)
|
|
21
21
|
|
|
22
22
|
perform(action, plan, take_snapshot("gitflash pick --#{action}", scope: %w[head worktree]))
|
|
@@ -21,7 +21,8 @@ module Gitflash
|
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def ignored_line(row)
|
|
24
|
-
"* #{row[:path]}: #{row[:ignored_count]}
|
|
24
|
+
"* #{TerminalText.line(row[:path])}: #{row[:ignored_count]} " \
|
|
25
|
+
"(#{TerminalText.line(row[:ignored].join(', '))})"
|
|
25
26
|
end
|
|
26
27
|
|
|
27
28
|
def report_results(plan, outcomes)
|
|
@@ -56,8 +57,12 @@ module Gitflash
|
|
|
56
57
|
end
|
|
57
58
|
|
|
58
59
|
def results_text(result)
|
|
59
|
-
lines = result[:removed].map
|
|
60
|
-
|
|
60
|
+
lines = result[:removed].map do |row|
|
|
61
|
+
"Removed #{TerminalText.line(row[:path])} (undo: #{row[:undo]})"
|
|
62
|
+
end
|
|
63
|
+
lines += result[:failed].map do |row|
|
|
64
|
+
"Not removed #{TerminalText.line(row[:path])}: #{row[:error]}"
|
|
65
|
+
end
|
|
61
66
|
lines.join("\n")
|
|
62
67
|
end
|
|
63
68
|
end
|
|
@@ -20,10 +20,12 @@ module Gitflash
|
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def text(snapshot)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
"
|
|
23
|
+
lines = ["Saved snapshot #{snapshot.id}"]
|
|
24
|
+
lines << "Skipped large untracked files: #{snapshot.skipped_files.join(', ')}" if
|
|
25
|
+
snapshot.skipped_files.any?
|
|
26
|
+
lines << "Left out by snapshot_exclude: #{snapshot.excluded_files.join(', ')}" if
|
|
27
|
+
snapshot.excluded_files.any?
|
|
28
|
+
lines.join("\n")
|
|
27
29
|
end
|
|
28
30
|
end
|
|
29
31
|
end
|
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
module Gitflash
|
|
4
4
|
module Commands
|
|
5
|
-
# Restores a snapshot (the latest one
|
|
6
|
-
# so an undo can itself be undone.
|
|
5
|
+
# Restores a snapshot (by default the latest one taken in this worktree). The current state
|
|
6
|
+
# is saved first, so an undo can itself be undone.
|
|
7
7
|
class Undo < Base
|
|
8
8
|
def call(id = nil)
|
|
9
9
|
snapshot = find(id)
|
|
10
10
|
return no_snapshots unless snapshot
|
|
11
11
|
|
|
12
12
|
restore = build_restore(snapshot)
|
|
13
|
-
plan = restore.plan.merge(snapshot: snapshot.id)
|
|
13
|
+
plan = restore.plan.merge(snapshot: snapshot.id, reason: snapshot.reason)
|
|
14
14
|
return nothing_to_undo(plan) if restore.empty?
|
|
15
15
|
|
|
16
16
|
restore!(snapshot, restore, plan)
|
|
@@ -18,12 +18,19 @@ module Gitflash
|
|
|
18
18
|
|
|
19
19
|
private
|
|
20
20
|
|
|
21
|
+
# Snapshots are shared by all worktrees of the repository, and another agent may have
|
|
22
|
+
# taken the newest one elsewhere; without an ID only this worktree's count
|
|
21
23
|
def find(id)
|
|
22
|
-
return snapshots.list.
|
|
24
|
+
return snapshots.list.find { |snapshot| own?(snapshot) } if id.nil?
|
|
23
25
|
|
|
24
26
|
snapshots.find(id) || usage_error!('unknown_snapshot', "Unknown snapshot '#{id}'")
|
|
25
27
|
end
|
|
26
28
|
|
|
29
|
+
# Taken in this worktree; snapshots from before worktree_path was recorded count too
|
|
30
|
+
def own?(snapshot)
|
|
31
|
+
snapshot.worktree_path.nil? || snapshot.worktree_path == repo.toplevel
|
|
32
|
+
end
|
|
33
|
+
|
|
27
34
|
# A snapshot of a worktree that is gone is restored by adding the worktree again
|
|
28
35
|
def build_restore(snapshot)
|
|
29
36
|
return WorktreeRevival.new(snapshot, repo: repo) if removed_worktree?(snapshot)
|
|
@@ -34,14 +41,14 @@ module Gitflash
|
|
|
34
41
|
|
|
35
42
|
# The snapshot was taken in a worktree whose directory no longer exists
|
|
36
43
|
def removed_worktree?(snapshot)
|
|
37
|
-
(snapshot.scope?('head') || snapshot.scope?('worktree')) &&
|
|
38
|
-
|
|
44
|
+
!own?(snapshot) && (snapshot.scope?('head') || snapshot.scope?('worktree')) &&
|
|
45
|
+
!File.exist?(snapshot.worktree_path)
|
|
39
46
|
end
|
|
40
47
|
|
|
41
48
|
# Branches are shared between worktrees; HEAD and files belong to one worktree
|
|
42
49
|
def check_worktree!(snapshot)
|
|
43
50
|
return unless snapshot.scope?('head') || snapshot.scope?('worktree')
|
|
44
|
-
return if snapshot
|
|
51
|
+
return if own?(snapshot)
|
|
45
52
|
|
|
46
53
|
usage_error!('wrong_worktree', "Snapshot #{snapshot.id} was taken in " \
|
|
47
54
|
"#{snapshot.worktree_path}; run gitflash undo there")
|
|
@@ -80,12 +87,16 @@ module Gitflash
|
|
|
80
87
|
def revival_hint(plan)
|
|
81
88
|
return '' unless plan[:recreate_worktree]
|
|
82
89
|
|
|
83
|
-
|
|
84
|
-
|
|
90
|
+
path = TerminalText.line(plan[:recreate_worktree])
|
|
91
|
+
"\nThe worktree #{path} was added again and may be partly restored; " \
|
|
92
|
+
"remove it with: gitflash wt remove #{path} --force"
|
|
85
93
|
end
|
|
86
94
|
|
|
87
95
|
def no_snapshots
|
|
88
|
-
ui.report(status: 'noop', result: { snapshot: nil },
|
|
96
|
+
ui.report(status: 'noop', result: { snapshot: nil },
|
|
97
|
+
text: 'No snapshots of this worktree to undo. `gitflash snapshots` lists all: ' \
|
|
98
|
+
'run `gitflash undo ID` in the worktree a snapshot was taken in (an ' \
|
|
99
|
+
'ID from elsewhere works here only for a branches-only snapshot)')
|
|
89
100
|
end
|
|
90
101
|
|
|
91
102
|
def nothing_to_undo(plan)
|
|
@@ -100,7 +111,9 @@ module Gitflash
|
|
|
100
111
|
|
|
101
112
|
def describe(plan)
|
|
102
113
|
lines = plan[:branches].map { |change| branch_line(change) }
|
|
103
|
-
|
|
114
|
+
if plan[:recreate_worktree]
|
|
115
|
+
lines << "* add worktree #{TerminalText.line(plan[:recreate_worktree])} again"
|
|
116
|
+
end
|
|
104
117
|
lines << head_line(plan[:head]) if plan[:head] && !plan[:recreate_worktree]
|
|
105
118
|
(lines + file_lines(plan)).join("\n")
|
|
106
119
|
end
|
|
@@ -15,7 +15,7 @@ module Gitflash
|
|
|
15
15
|
plan = build_plan(branch, source)
|
|
16
16
|
if ui.dry_run?
|
|
17
17
|
return planned(plan,
|
|
18
|
-
"Would add worktree #{plan[:path]} on #{branch} (#{source})")
|
|
18
|
+
"Would add worktree #{shown(plan[:path])} on #{branch} (#{source})")
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
add(worktrees, plan)
|
|
@@ -48,13 +48,15 @@ module Gitflash
|
|
|
48
48
|
holder = worktrees.list.find { |worktree| worktree.branch == branch }
|
|
49
49
|
return unless holder
|
|
50
50
|
|
|
51
|
-
usage_error!('invalid_usage',
|
|
51
|
+
usage_error!('invalid_usage',
|
|
52
|
+
"Branch #{branch} is already checked out at #{TerminalText.line(holder.path)}")
|
|
52
53
|
end
|
|
53
54
|
|
|
54
55
|
def build_plan(branch, source)
|
|
55
56
|
path = target_path(branch)
|
|
56
57
|
if File.exist?(path) && !(File.directory?(path) && Dir.empty?(path))
|
|
57
|
-
usage_error!('invalid_usage',
|
|
58
|
+
usage_error!('invalid_usage',
|
|
59
|
+
"#{TerminalText.line(path)} already exists and is not an empty directory")
|
|
58
60
|
end
|
|
59
61
|
|
|
60
62
|
{ branch: branch, path: path, source: source.to_s, from: options[:from],
|
|
@@ -86,7 +88,7 @@ module Gitflash
|
|
|
86
88
|
|
|
87
89
|
def report(plan)
|
|
88
90
|
ui.report(status: 'done', plan: plan, result: outcome(plan),
|
|
89
|
-
text: "Added worktree #{plan[:path]} on #{plan[:branch]}")
|
|
91
|
+
text: "Added worktree #{TerminalText.line(plan[:path])} on #{plan[:branch]}")
|
|
90
92
|
end
|
|
91
93
|
|
|
92
94
|
def outcome(plan)
|
|
@@ -11,10 +11,24 @@ module Gitflash
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
# The worktree named by a path or a branch; raises `unknown_worktree` when none matches
|
|
14
|
+
# A target that names one worktree by path and another by branch is refused, so a command
|
|
15
|
+
# never acts on the worktree the caller did not mean
|
|
14
16
|
def find!(target, list = worktrees.list)
|
|
15
|
-
path =
|
|
16
|
-
list.
|
|
17
|
-
|
|
17
|
+
path = expand(target)
|
|
18
|
+
found = list.select { |worktree| worktree.path == path || worktree.branch == target }
|
|
19
|
+
usage_error!('unknown_worktree', "Unknown worktree: #{target}") if found.empty?
|
|
20
|
+
return found.first if found.one?
|
|
21
|
+
|
|
22
|
+
paths = found.map { |worktree| TerminalText.line(worktree.path) }.join(', ')
|
|
23
|
+
usage_error!('invalid_usage', "#{target} names more than one worktree (#{paths}); " \
|
|
24
|
+
'pass the full path')
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# File.expand_path, but `~name` for an unknown user is a usage error, not a crash
|
|
28
|
+
def expand(path)
|
|
29
|
+
File.expand_path(path)
|
|
30
|
+
rescue ArgumentError => e
|
|
31
|
+
usage_error!('invalid_options', "Cannot read the path #{path}: #{e.message}")
|
|
18
32
|
end
|
|
19
33
|
|
|
20
34
|
def refuse_main!(worktree, action)
|
|
@@ -13,13 +13,13 @@ module Gitflash
|
|
|
13
13
|
private
|
|
14
14
|
|
|
15
15
|
def table(worktrees)
|
|
16
|
-
width = worktrees.map { |worktree| worktree.path.length }.max
|
|
16
|
+
width = worktrees.map { |worktree| TerminalText.line(worktree.path).length }.max
|
|
17
17
|
worktrees.map { |worktree| row(worktree, width) }.join("\n")
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def row(worktree, width)
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
name = worktree.branch || "(detached #{worktree.head.to_s[0, 7]})"
|
|
22
|
+
[TerminalText.line(worktree.path).ljust(width), name, flags(worktree)].join(' ').rstrip
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def flags(worktree)
|
|
@@ -13,7 +13,10 @@ module Gitflash
|
|
|
13
13
|
worktree = find!(target)
|
|
14
14
|
refuse_main!(worktree, verb)
|
|
15
15
|
return noop(worktree) if worktree.locked? == lock?
|
|
16
|
-
|
|
16
|
+
if ui.dry_run?
|
|
17
|
+
return planned(plan(worktree),
|
|
18
|
+
"Would #{verb} #{TerminalText.line(worktree.path)}")
|
|
19
|
+
end
|
|
17
20
|
|
|
18
21
|
change(worktree)
|
|
19
22
|
end
|
|
@@ -32,7 +35,7 @@ module Gitflash
|
|
|
32
35
|
result = git_change(worktree)
|
|
33
36
|
git_error!("worktree #{verb}", result) unless result.success?
|
|
34
37
|
|
|
35
|
-
report('done', worktree, "#{verb.capitalize}ed #{worktree.path}")
|
|
38
|
+
report('done', worktree, "#{verb.capitalize}ed #{TerminalText.line(worktree.path)}")
|
|
36
39
|
end
|
|
37
40
|
|
|
38
41
|
def git_change(worktree)
|
|
@@ -42,7 +45,8 @@ module Gitflash
|
|
|
42
45
|
end
|
|
43
46
|
|
|
44
47
|
def noop(worktree)
|
|
45
|
-
report('noop', worktree,
|
|
48
|
+
report('noop', worktree,
|
|
49
|
+
"#{TerminalText.line(worktree.path)} is #{lock? ? 'already locked' : 'not locked'}")
|
|
46
50
|
end
|
|
47
51
|
|
|
48
52
|
def report(status, worktree, text)
|
|
@@ -14,7 +14,10 @@ module Gitflash
|
|
|
14
14
|
worktree = find!(target)
|
|
15
15
|
check!(worktree)
|
|
16
16
|
plan = { from: worktree.path, to: File.expand_path(destination), force: force? }
|
|
17
|
-
|
|
17
|
+
if ui.dry_run?
|
|
18
|
+
return planned(plan,
|
|
19
|
+
"Would move #{shown(plan[:from])} to #{shown(plan[:to])}")
|
|
20
|
+
end
|
|
18
21
|
|
|
19
22
|
move(plan)
|
|
20
23
|
end
|
|
@@ -28,11 +31,13 @@ module Gitflash
|
|
|
28
31
|
def check!(worktree)
|
|
29
32
|
refuse_main!(worktree, 'move')
|
|
30
33
|
if worktree.path == repo.toplevel
|
|
31
|
-
usage_error!('protected_worktree',
|
|
34
|
+
usage_error!('protected_worktree',
|
|
35
|
+
"#{TerminalText.line(worktree.path)} is the current worktree")
|
|
32
36
|
end
|
|
33
37
|
return unless worktree.locked? && !force?
|
|
34
38
|
|
|
35
|
-
usage_error!('protected_worktree',
|
|
39
|
+
usage_error!('protected_worktree',
|
|
40
|
+
"#{TerminalText.line(worktree.path)} is locked (use --force)")
|
|
36
41
|
end
|
|
37
42
|
|
|
38
43
|
def move(plan)
|
|
@@ -40,7 +45,7 @@ module Gitflash
|
|
|
40
45
|
git_error!('worktree move', result) unless result.success?
|
|
41
46
|
|
|
42
47
|
ui.report(status: 'done', plan: plan, result: plan.slice(:from, :to),
|
|
43
|
-
text: "Moved #{plan[:from]} to #{plan[:to]}")
|
|
48
|
+
text: "Moved #{shown(plan[:from])} to #{shown(plan[:to])}")
|
|
44
49
|
end
|
|
45
50
|
end
|
|
46
51
|
end
|
|
@@ -6,7 +6,8 @@ module Gitflash
|
|
|
6
6
|
# The main checkout, the worktree gitflash runs in, locked and dirty worktrees are refused
|
|
7
7
|
# unless --force is given (the main checkout and the current one never can be removed).
|
|
8
8
|
# Each removal saves a snapshot of the worktree first; `gitflash undo ID` brings the
|
|
9
|
-
# directory, its branch and its uncommitted files back.
|
|
9
|
+
# directory, its branch and its uncommitted files back. Ignored files cannot be saved, so a
|
|
10
|
+
# removal that deletes some needs --confirm TOKEN instead of --yes.
|
|
10
11
|
class WorktreeRemove < WorktreeCommand
|
|
11
12
|
include RemovalReport
|
|
12
13
|
|
|
@@ -24,13 +25,18 @@ module Gitflash
|
|
|
24
25
|
def pick(worktrees)
|
|
25
26
|
require_interactive!('Pass the worktrees to remove: gitflash wt remove PATH_OR_BRANCH...')
|
|
26
27
|
choices = worktrees.reject { |worktree| worktree.main? || current?(worktree) }
|
|
27
|
-
.to_h { |wt| [
|
|
28
|
+
.to_h { |wt| [menu_label(wt), wt.path] }
|
|
28
29
|
return report_nothing('No worktrees available to remove') if choices.empty?
|
|
29
30
|
|
|
30
31
|
picked = ui.multi_select('Select worktrees to remove', choices)
|
|
31
32
|
picked.empty? ? report_nothing('No worktrees selected') : picked
|
|
32
33
|
end
|
|
33
34
|
|
|
35
|
+
# Paths come from the repository, so control characters in them are shown as escapes
|
|
36
|
+
def menu_label(worktree)
|
|
37
|
+
TerminalText.line("#{worktree.branch || '(detached)'} #{worktree.path}")
|
|
38
|
+
end
|
|
39
|
+
|
|
34
40
|
def force?
|
|
35
41
|
options[:force] ? true : false
|
|
36
42
|
end
|
|
@@ -51,7 +57,11 @@ module Gitflash
|
|
|
51
57
|
def remove(chosen)
|
|
52
58
|
plan = build_plan(chosen)
|
|
53
59
|
return planned(plan, "Would remove:\n#{preview(chosen, plan)}") if dry_run?
|
|
54
|
-
|
|
60
|
+
|
|
61
|
+
# Ignored files are never saved, so losing them needs --confirm TOKEN, not --yes
|
|
62
|
+
confirmed = ui.confirm?(summary(chosen, plan), plan: plan,
|
|
63
|
+
irreversible: loses_ignored?(plan))
|
|
64
|
+
return cancelled(plan) unless confirmed
|
|
55
65
|
|
|
56
66
|
outcomes = chosen.map { |worktree| remove_one(worktree) }
|
|
57
67
|
report_results(plan, outcomes)
|
|
@@ -59,6 +69,10 @@ module Gitflash
|
|
|
59
69
|
|
|
60
70
|
def dry_run? = ui.dry_run?
|
|
61
71
|
|
|
72
|
+
def loses_ignored?(plan)
|
|
73
|
+
plan[:worktrees].any? { |row| row[:ignored_count].positive? }
|
|
74
|
+
end
|
|
75
|
+
|
|
62
76
|
def preview(chosen, plan) = "#{bullets(chosen)}#{ignored_warning(plan)}"
|
|
63
77
|
|
|
64
78
|
# The plan reported and returned in JSON; `wt clean` adds why each worktree was chosen
|
|
@@ -74,7 +88,7 @@ module Gitflash
|
|
|
74
88
|
|
|
75
89
|
def remove_one(worktree)
|
|
76
90
|
snapshot = snapshot_for(worktree)
|
|
77
|
-
unsaved = snapshot.
|
|
91
|
+
unsaved = snapshot.unsaved_files
|
|
78
92
|
return refuse_unsaved(worktree, unsaved) if unsaved.any?
|
|
79
93
|
|
|
80
94
|
result = Worktrees.new(repo: repo).remove(worktree.path, force: force?)
|
|
@@ -85,8 +99,8 @@ module Gitflash
|
|
|
85
99
|
def refuse_unsaved(worktree, files)
|
|
86
100
|
shown = files.first(3).join(', ')
|
|
87
101
|
shown += ", and #{files.size - 3} more" if files.size > 3
|
|
88
|
-
message = "#{files.size} untracked file(s)
|
|
89
|
-
"
|
|
102
|
+
message = "#{files.size} untracked file(s) cannot be saved by a snapshot (over 50 MB or " \
|
|
103
|
+
"matched by snapshot_exclude: #{shown}); move or delete them first"
|
|
90
104
|
Removal.new(worktree: worktree, result: Result.new(success: false, output: message),
|
|
91
105
|
snapshot: nil)
|
|
92
106
|
end
|
|
@@ -107,7 +121,9 @@ module Gitflash
|
|
|
107
121
|
end
|
|
108
122
|
|
|
109
123
|
def bullets(chosen)
|
|
110
|
-
chosen.map { |worktree|
|
|
124
|
+
chosen.map { |worktree|
|
|
125
|
+
"* #{TerminalText.line(worktree.path)} (#{worktree.branch || 'detached'})"
|
|
126
|
+
}.join("\n")
|
|
111
127
|
end
|
|
112
128
|
|
|
113
129
|
def report_nothing(text)
|