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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -0
  3. data/README.md +17 -13
  4. data/command_descriptions.yml +25 -7
  5. data/lib/gitflash/atomic_file.rb +40 -0
  6. data/lib/gitflash/cherry_pick/log_parser.rb +63 -0
  7. data/lib/gitflash/cherry_pick.rb +10 -18
  8. data/lib/gitflash/cli.rb +28 -5
  9. data/lib/gitflash/command_runner.rb +12 -1
  10. data/lib/gitflash/commands/base.rb +3 -0
  11. data/lib/gitflash/commands/clean.rb +29 -1
  12. data/lib/gitflash/commands/gc.rb +19 -4
  13. data/lib/gitflash/commands/hook_install.rb +79 -12
  14. data/lib/gitflash/commands/mark.rb +9 -3
  15. data/lib/gitflash/commands/pick.rb +13 -1
  16. data/lib/gitflash/commands/pick_control.rb +1 -1
  17. data/lib/gitflash/commands/removal_report.rb +8 -3
  18. data/lib/gitflash/commands/snapshot_create.rb +6 -4
  19. data/lib/gitflash/commands/undo.rb +24 -11
  20. data/lib/gitflash/commands/worktree_add.rb +6 -4
  21. data/lib/gitflash/commands/worktree_command.rb +17 -3
  22. data/lib/gitflash/commands/worktree_list.rb +3 -3
  23. data/lib/gitflash/commands/worktree_lock.rb +7 -3
  24. data/lib/gitflash/commands/worktree_move.rb +9 -4
  25. data/lib/gitflash/commands/worktree_prune.rb +1 -1
  26. data/lib/gitflash/commands/worktree_remove.rb +23 -7
  27. data/lib/gitflash/config.rb +69 -16
  28. data/lib/gitflash/config_keys.rb +64 -4
  29. data/lib/gitflash/confirmation_required.rb +6 -2
  30. data/lib/gitflash/git/bash_command.rb +5 -2
  31. data/lib/gitflash/hook/branch_marker.rb +109 -6
  32. data/lib/gitflash/hook/claude.rb +45 -24
  33. data/lib/gitflash/hook/command_parser.rb +100 -59
  34. data/lib/gitflash/hook/command_words.rb +73 -0
  35. data/lib/gitflash/hook/failure.rb +29 -0
  36. data/lib/gitflash/hook/git_invocation.rb +119 -0
  37. data/lib/gitflash/hook/history_rules.rb +81 -0
  38. data/lib/gitflash/hook/losses.rb +51 -0
  39. data/lib/gitflash/hook/messages.rb +64 -0
  40. data/lib/gitflash/hook/paths.rb +47 -0
  41. data/lib/gitflash/hook/rules.rb +36 -12
  42. data/lib/gitflash/hook/settings.rb +19 -2
  43. data/lib/gitflash/hook/shell_quotes.rb +64 -0
  44. data/lib/gitflash/hook/shell_text.rb +101 -0
  45. data/lib/gitflash/hook/wrappers.rb +67 -0
  46. data/lib/gitflash/hook_cli.rb +34 -4
  47. data/lib/gitflash/protection.rb +12 -4
  48. data/lib/gitflash/repo.rb +3 -2
  49. data/lib/gitflash/snapshot.rb +9 -2
  50. data/lib/gitflash/snapshot_check.rb +60 -0
  51. data/lib/gitflash/snapshots.rb +26 -3
  52. data/lib/gitflash/state_capture.rb +26 -24
  53. data/lib/gitflash/terminal_text.rb +9 -0
  54. data/lib/gitflash/ui.rb +32 -4
  55. data/lib/gitflash/untracked_files.rb +19 -0
  56. data/lib/gitflash/usage_report.rb +66 -0
  57. data/lib/gitflash/version.rb +1 -1
  58. data/lib/gitflash/worktree.rb +4 -4
  59. data/schema/v1.json +39 -2
  60. 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
- { scope: scope, file: settings.path(scope), command: hook_command,
34
- marking_command: MARKING_COMMAND, applies_to: settings.applies_to(scope) }
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
- FileUtils.mkdir_p(File.dirname(plan[:file]))
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
- hooks = (data['hooks'] ||= {})
70
- (hooks[event] ||= []) << {
71
- 'matcher' => 'Bash',
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`, default agent) or removes the mark
6
- # (`--clear`), so `gitflash clean --agent` knows which branches an agent created.
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 'agent' if given.nil?
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]} (#{row[:ignored].join(', ')})"
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 { |row| "Removed #{row[:path]} (undo: #{row[:undo]})" }
60
- lines += result[:failed].map { |row| "Not removed #{row[:path]}: #{row[:error]}" }
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
- text = "Saved snapshot #{snapshot.id}"
24
- return text if snapshot.skipped_files.empty?
25
-
26
- "#{text}\nSkipped large untracked files: #{snapshot.skipped_files.join(', ')}"
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 by default). The current state is saved first,
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.first if id.nil?
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
- snapshot.worktree_path != repo.toplevel && !File.exist?(snapshot.worktree_path)
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.worktree_path == repo.toplevel
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
- "\nThe worktree #{plan[:recreate_worktree]} was added again and may be partly restored; " \
84
- "remove it with: gitflash wt remove #{plan[:recreate_worktree]} --force"
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 }, text: 'No snapshots to undo')
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
- lines << "* add worktree #{plan[:recreate_worktree]} again" if plan[:recreate_worktree]
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', "Branch #{branch} is already checked out at #{holder.path}")
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', "#{path} already exists and is not an empty directory")
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 = File.expand_path(target)
16
- list.find { |worktree| worktree.path == path || worktree.branch == target } ||
17
- usage_error!('unknown_worktree', "Unknown worktree: #{target}")
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
- [worktree.path.ljust(width), worktree.branch || "(detached #{worktree.head.to_s[0, 7]})",
22
- flags(worktree)].join(' ').rstrip
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
- return planned(plan(worktree), "Would #{verb} #{worktree.path}") if ui.dry_run?
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, "#{worktree.path} is #{lock? ? 'already locked' : 'not locked'}")
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
- return planned(plan, "Would move #{plan[:from]} to #{plan[:to]}") if ui.dry_run?
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', "#{worktree.path} is the current 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', "#{worktree.path} is locked (use --force)")
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
@@ -34,7 +34,7 @@ module Gitflash
34
34
  end
35
35
 
36
36
  def bullets(plan)
37
- plan[:worktrees].map { |path| "* #{path}" }.join("\n")
37
+ plan[:worktrees].map { |path| "* #{TerminalText.line(path)}" }.join("\n")
38
38
  end
39
39
  end
40
40
  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| ["#{wt.branch || '(detached)'} #{wt.path}", wt.path] }
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
- return cancelled(plan) unless ui.confirm?(summary(chosen, plan), plan: plan)
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.skipped_files
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) over 50 MB cannot be saved by a snapshot " \
89
- "(#{shown}); move or delete them first"
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| "* #{worktree.path} (#{worktree.branch || 'detached'})" }.join("\n")
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)