gitflash 0.6.0 → 0.7.0.beta2

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/README.md +26 -2
  4. data/command_descriptions.yml +80 -0
  5. data/lib/gitflash/branch.rb +7 -2
  6. data/lib/gitflash/cherry_pick/log_parser.rb +63 -0
  7. data/lib/gitflash/cherry_pick.rb +120 -0
  8. data/lib/gitflash/cleanup.rb +70 -0
  9. data/lib/gitflash/cli.rb +38 -1
  10. data/lib/gitflash/commands/base.rb +12 -3
  11. data/lib/gitflash/commands/branches.rb +1 -0
  12. data/lib/gitflash/commands/clean.rb +51 -0
  13. data/lib/gitflash/commands/clean_criteria.rb +29 -0
  14. data/lib/gitflash/commands/delete.rb +17 -5
  15. data/lib/gitflash/commands/hook_install.rb +31 -10
  16. data/lib/gitflash/commands/hook_status.rb +24 -6
  17. data/lib/gitflash/commands/mark.rb +85 -0
  18. data/lib/gitflash/commands/pick.rb +143 -0
  19. data/lib/gitflash/commands/pick_control.rb +67 -0
  20. data/lib/gitflash/commands/pick_outcome.rb +52 -0
  21. data/lib/gitflash/commands/removal_report.rb +70 -0
  22. data/lib/gitflash/commands/undo.rb +41 -6
  23. data/lib/gitflash/commands/worktree_add.rb +100 -0
  24. data/lib/gitflash/commands/worktree_clean.rb +49 -0
  25. data/lib/gitflash/commands/worktree_command.rb +28 -0
  26. data/lib/gitflash/commands/worktree_list.rb +48 -0
  27. data/lib/gitflash/commands/worktree_lock.rb +58 -0
  28. data/lib/gitflash/commands/worktree_move.rb +52 -0
  29. data/lib/gitflash/commands/worktree_prune.rb +41 -0
  30. data/lib/gitflash/commands/worktree_remove.rb +126 -0
  31. data/lib/gitflash/commands/worktree_unlock.rb +12 -0
  32. data/lib/gitflash/config.rb +98 -0
  33. data/lib/gitflash/config_keys.rb +31 -0
  34. data/lib/gitflash/git/bash_command.rb +5 -2
  35. data/lib/gitflash/git/in_directory.rb +29 -0
  36. data/lib/gitflash/hook/branch_marker.rb +155 -0
  37. data/lib/gitflash/hook/claude.rb +54 -18
  38. data/lib/gitflash/hook/command_parser.rb +50 -12
  39. data/lib/gitflash/hook/creations.rb +85 -0
  40. data/lib/gitflash/hook/messages.rb +38 -0
  41. data/lib/gitflash/hook/rules.rb +6 -4
  42. data/lib/gitflash/hook/settings.rb +18 -5
  43. data/lib/gitflash/hook_cli.rb +20 -5
  44. data/lib/gitflash/ownership.rb +59 -0
  45. data/lib/gitflash/protection.rb +19 -0
  46. data/lib/gitflash/repo.rb +5 -5
  47. data/lib/gitflash/restore.rb +8 -7
  48. data/lib/gitflash/state_capture.rb +10 -5
  49. data/lib/gitflash/terminal_text.rb +9 -0
  50. data/lib/gitflash/ui.rb +1 -1
  51. data/lib/gitflash/version.rb +1 -1
  52. data/lib/gitflash/worktree.rb +32 -0
  53. data/lib/gitflash/worktree_cli.rb +115 -0
  54. data/lib/gitflash/worktree_revival.rb +55 -0
  55. data/lib/gitflash/worktrees.rb +147 -0
  56. data/schema/v1.json +1050 -2
  57. metadata +32 -1
@@ -9,8 +9,7 @@ module Gitflash
9
9
  snapshot = find(id)
10
10
  return no_snapshots unless snapshot
11
11
 
12
- check_worktree!(snapshot)
13
- restore = Restore.new(snapshot)
12
+ restore = build_restore(snapshot)
14
13
  plan = restore.plan.merge(snapshot: snapshot.id)
15
14
  return nothing_to_undo(plan) if restore.empty?
16
15
 
@@ -25,6 +24,20 @@ module Gitflash
25
24
  snapshots.find(id) || usage_error!('unknown_snapshot', "Unknown snapshot '#{id}'")
26
25
  end
27
26
 
27
+ # A snapshot of a worktree that is gone is restored by adding the worktree again
28
+ def build_restore(snapshot)
29
+ return WorktreeRevival.new(snapshot, repo: repo) if removed_worktree?(snapshot)
30
+
31
+ check_worktree!(snapshot)
32
+ Restore.new(snapshot)
33
+ end
34
+
35
+ # The snapshot was taken in a worktree whose directory no longer exists
36
+ def removed_worktree?(snapshot)
37
+ (snapshot.scope?('head') || snapshot.scope?('worktree')) &&
38
+ snapshot.worktree_path != repo.toplevel && !File.exist?(snapshot.worktree_path)
39
+ end
40
+
28
41
  # Branches are shared between worktrees; HEAD and files belong to one worktree
29
42
  def check_worktree!(snapshot)
30
43
  return unless snapshot.scope?('head') || snapshot.scope?('worktree')
@@ -43,21 +56,35 @@ module Gitflash
43
56
  end
44
57
 
45
58
  def apply(snapshot, restore, plan)
46
- before = snapshots.create(reason: "before undo #{snapshot.id}", scope: snapshot.scope,
59
+ before = snapshots.create(reason: "before undo #{snapshot.id}", scope: restore.scope,
47
60
  branches: snapshot.branches.keys)
48
61
  failure = restore.apply
49
62
  raise_failure(failure, before, plan) if failure
50
63
 
64
+ report_done(snapshot, plan, before)
65
+ end
66
+
67
+ def report_done(snapshot, plan, before)
51
68
  ui.report(status: 'done', plan: plan, result: { snapshot: snapshot.id }, undo: before,
52
69
  text: "Restored snapshot #{snapshot.id}:\n#{describe(plan)}")
53
70
  end
54
71
 
55
72
  def raise_failure(failure, before, plan)
56
73
  message = "gitflash undo failed:\n#{failure.output}\n" \
57
- "The state before this undo is saved as snapshot #{before.id}."
74
+ "The state before this undo is saved as snapshot #{before.id}." \
75
+ "#{revival_hint(plan)}"
58
76
  raise Error.new(message, code: 'git_failed', plan: plan)
59
77
  end
60
78
 
79
+ # That snapshot cannot take the added worktree away again
80
+ def revival_hint(plan)
81
+ return '' unless plan[:recreate_worktree]
82
+
83
+ path = TerminalText.line(plan[:recreate_worktree])
84
+ "\nThe worktree #{path} was added again and may be partly restored; " \
85
+ "remove it with: gitflash wt remove #{path} --force"
86
+ end
87
+
61
88
  def no_snapshots
62
89
  ui.report(status: 'noop', result: { snapshot: nil }, text: 'No snapshots to undo')
63
90
  end
@@ -74,10 +101,18 @@ module Gitflash
74
101
 
75
102
  def describe(plan)
76
103
  lines = plan[:branches].map { |change| branch_line(change) }
77
- lines << head_line(plan[:head]) if plan[:head]
104
+ if plan[:recreate_worktree]
105
+ lines << "* add worktree #{TerminalText.line(plan[:recreate_worktree])} again"
106
+ end
107
+ lines << head_line(plan[:head]) if plan[:head] && !plan[:recreate_worktree]
108
+ (lines + file_lines(plan)).join("\n")
109
+ end
110
+
111
+ def file_lines(plan)
112
+ lines = []
78
113
  lines << '* restore tracked, staged and untracked files' if plan[:worktree]
79
114
  lines << "* restore #{plan[:stashes].size} stash entries" if plan[:stashes].any?
80
- lines.join("\n")
115
+ lines
81
116
  end
82
117
 
83
118
  def branch_line(change)
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Adds a worktree for an existing, remote or new branch. Additive, so it needs no
6
+ # confirmation and no snapshot. A branch created here is marked as agent work.
7
+ class WorktreeAdd < Base
8
+ def call(branch = nil)
9
+ validate_name!(branch)
10
+ worktrees = Worktrees.new(repo: repo)
11
+ source = worktrees.source_for(branch)
12
+ check_options!(source)
13
+ check_free!(worktrees, branch, source)
14
+
15
+ plan = build_plan(branch, source)
16
+ if ui.dry_run?
17
+ return planned(plan,
18
+ "Would add worktree #{shown(plan[:path])} on #{branch} (#{source})")
19
+ end
20
+
21
+ add(worktrees, plan)
22
+ end
23
+
24
+ private
25
+
26
+ def validate_name!(branch)
27
+ usage_error!('invalid_usage', 'Pass the branch: gitflash wt add BRANCH') if branch.nil?
28
+ valid = !branch.start_with?('-') && ref_format_ok?(branch)
29
+ usage_error!('invalid_usage', "Invalid branch name '#{branch}'") unless valid
30
+ end
31
+
32
+ def ref_format_ok?(branch)
33
+ _stdout, _stderr, success = Git::BashCommand.capture('git', 'check-ref-format', '--branch',
34
+ branch)
35
+ success
36
+ end
37
+
38
+ def check_options!(source)
39
+ return unless options[:from] && source != :new
40
+
41
+ usage_error!('invalid_options', '--from only applies to a new branch, but the branch ' \
42
+ "already exists (#{source})")
43
+ end
44
+
45
+ def check_free!(worktrees, branch, source)
46
+ return unless source == :existing
47
+
48
+ holder = worktrees.list.find { |worktree| worktree.branch == branch }
49
+ return unless holder
50
+
51
+ usage_error!('invalid_usage',
52
+ "Branch #{branch} is already checked out at #{TerminalText.line(holder.path)}")
53
+ end
54
+
55
+ def build_plan(branch, source)
56
+ path = target_path(branch)
57
+ if File.exist?(path) && !(File.directory?(path) && Dir.empty?(path))
58
+ usage_error!('invalid_usage',
59
+ "#{TerminalText.line(path)} already exists and is not an empty directory")
60
+ end
61
+
62
+ { branch: branch, path: path, source: source.to_s, from: options[:from],
63
+ owner: source == :existing ? nil : owner }
64
+ end
65
+
66
+ def target_path(branch)
67
+ return File.expand_path(options[:path]) if options[:path]
68
+
69
+ config.worktree_path(root: repo.main_root, branch: branch)
70
+ end
71
+
72
+ def owner
73
+ value = options[:owner] || 'agent'
74
+ return value if Ownership::OWNERS.include?(value)
75
+
76
+ usage_error!('invalid_options', "Unknown owner '#{value}' (use agent or human)")
77
+ end
78
+
79
+ def add(worktrees, plan)
80
+ source = plan[:source].to_sym
81
+ result = worktrees.add(plan[:path], branch: plan[:branch], source: source,
82
+ start: plan[:from])
83
+ git_error!('worktree add', result) unless result.success?
84
+
85
+ Ownership.new.mark(plan[:branch], plan[:owner]) if plan[:owner]
86
+ report(plan)
87
+ end
88
+
89
+ def report(plan)
90
+ ui.report(status: 'done', plan: plan, result: outcome(plan),
91
+ text: "Added worktree #{TerminalText.line(plan[:path])} on #{plan[:branch]}")
92
+ end
93
+
94
+ def outcome(plan)
95
+ head = Git::BashCommand.exec('git', '-C', plan[:path], 'rev-parse', 'HEAD').strip
96
+ plan.slice(:branch, :path, :source, :owner).merge(head: head)
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Removes the worktrees whose branch is no longer needed (same criteria as `clean`).
6
+ # Main, current, locked and dirty worktrees are skipped with the reason unless --force
7
+ # allows locked and dirty ones; the main checkout and the current worktree never go.
8
+ # Removal, snapshots and undo work as in `wt remove`. Branches are kept: run `gitflash clean`
9
+ # afterwards to delete them. Worktrees with a missing directory belong to `wt prune`.
10
+ class WorktreeClean < WorktreeRemove
11
+ include CleanCriteria
12
+
13
+ def call
14
+ chosen, @skipped = select
15
+ @reasons = chosen.to_h { |candidate| [candidate.item.path, candidate.reasons] }
16
+ return nothing_to_clean if chosen.empty?
17
+
18
+ remove(chosen.map(&:item))
19
+ end
20
+
21
+ private
22
+
23
+ def select
24
+ Cleanup.worktrees(worktrees.list, branches: repo.branches(merged_status: true),
25
+ rules: rules, protection: Protection.new(config: config),
26
+ blocker: ->(worktree) { blocker(worktree) })
27
+ end
28
+
29
+ def blocker(worktree)
30
+ worktree.removal_blocker(force: force?, current: current?(worktree))
31
+ end
32
+
33
+ def build_plan(chosen)
34
+ super.merge(skipped: @skipped)
35
+ end
36
+
37
+ def plan_row(worktree)
38
+ super.merge(reasons: @reasons[worktree.path])
39
+ end
40
+
41
+ def nothing_to_clean
42
+ plan = { worktrees: [], force: force?, skipped: @skipped }
43
+ text = (['Nothing to clean'] + @skipped.map { |row| "Kept #{row[:path]}: #{row[:reason]}" })
44
+ ui.report(status: 'noop', plan: plan, result: { removed: [], failed: [] },
45
+ text: text.join("\n"))
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Shared lookup for the `worktree` commands that act on one worktree
6
+ class WorktreeCommand < Base
7
+ private
8
+
9
+ def worktrees
10
+ @worktrees ||= Worktrees.new(repo: repo)
11
+ end
12
+
13
+ # The worktree named by a path or a branch; raises `unknown_worktree` when none matches
14
+ 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}")
18
+ end
19
+
20
+ def refuse_main!(worktree, action)
21
+ return unless worktree.main?
22
+
23
+ usage_error!('protected_worktree',
24
+ "#{worktree.path} is the main checkout; cannot #{action}")
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Lists the worktrees of the repository
6
+ class WorktreeList < Base
7
+ def call
8
+ worktrees = Worktrees.new(repo: repo).list
9
+ ui.report(status: 'done', result: { worktrees: worktrees.map(&:to_h) },
10
+ text: table(worktrees))
11
+ end
12
+
13
+ private
14
+
15
+ def table(worktrees)
16
+ width = worktrees.map { |worktree| TerminalText.line(worktree.path).length }.max
17
+ worktrees.map { |worktree| row(worktree, width) }.join("\n")
18
+ end
19
+
20
+ def row(worktree, width)
21
+ name = worktree.branch || "(detached #{worktree.head.to_s[0, 7]})"
22
+ [TerminalText.line(worktree.path).ljust(width), name, flags(worktree)].join(' ').rstrip
23
+ end
24
+
25
+ def flags(worktree)
26
+ labels = state_labels(worktree) + position_labels(worktree)
27
+ labels.select { |_label, shown| shown }.map(&:first).join(', ')
28
+ end
29
+
30
+ def state_labels(worktree)
31
+ [['main', worktree.main?], ['agent', worktree.owner == 'agent'],
32
+ ['dirty', worktree.dirty?], ['dirty state unknown', unknown_dirty?(worktree)],
33
+ ['locked', worktree.locked?], ['missing', worktree.missing?],
34
+ ['merged', worktree.merged && worktree.branch != repo.default_branch]]
35
+ end
36
+
37
+ # git status failed in a directory that exists; JSON keeps `dirty: null`
38
+ def unknown_dirty?(worktree)
39
+ worktree.dirty.nil? && !worktree.missing? && !worktree.bare
40
+ end
41
+
42
+ def position_labels(worktree)
43
+ [["ahead #{worktree.ahead}", worktree.ahead.to_i.positive?],
44
+ ["behind #{worktree.behind}", worktree.behind.to_i.positive?]]
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Locks or unlocks a worktree, so `prune`, `remove` and `clean` leave it alone.
6
+ # Needs no confirmation: the change is reversed by the opposite command.
7
+ class WorktreeLock < WorktreeCommand
8
+ def call(target = nil)
9
+ unless target
10
+ usage_error!('invalid_usage',
11
+ "Pass the worktree: gitflash wt #{verb} WORKTREE")
12
+ end
13
+ worktree = find!(target)
14
+ refuse_main!(worktree, verb)
15
+ return noop(worktree) if worktree.locked? == lock?
16
+ if ui.dry_run?
17
+ return planned(plan(worktree),
18
+ "Would #{verb} #{TerminalText.line(worktree.path)}")
19
+ end
20
+
21
+ change(worktree)
22
+ end
23
+
24
+ private
25
+
26
+ def lock? = true
27
+
28
+ def verb = lock? ? 'lock' : 'unlock'
29
+
30
+ def plan(worktree)
31
+ { path: worktree.path, reason: (options[:reason] if lock?) }.compact
32
+ end
33
+
34
+ def change(worktree)
35
+ result = git_change(worktree)
36
+ git_error!("worktree #{verb}", result) unless result.success?
37
+
38
+ report('done', worktree, "#{verb.capitalize}ed #{TerminalText.line(worktree.path)}")
39
+ end
40
+
41
+ def git_change(worktree)
42
+ return worktrees.unlock(worktree.path) unless lock?
43
+
44
+ worktrees.lock(worktree.path, reason: options[:reason])
45
+ end
46
+
47
+ def noop(worktree)
48
+ report('noop', worktree,
49
+ "#{TerminalText.line(worktree.path)} is #{lock? ? 'already locked' : 'not locked'}")
50
+ end
51
+
52
+ def report(status, worktree, text)
53
+ ui.report(status: status, plan: plan(worktree), text: text,
54
+ result: plan(worktree).merge(locked: lock?))
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Moves a worktree to another directory. Refuses the main checkout, the current worktree
6
+ # (its directory would disappear under the caller) and locked worktrees unless --force.
7
+ # Needs no confirmation: moving it back reverses it.
8
+ class WorktreeMove < WorktreeCommand
9
+ def call(target = nil, destination = nil)
10
+ unless target && destination
11
+ usage_error!('invalid_usage',
12
+ 'Pass the worktree and its new path')
13
+ end
14
+ worktree = find!(target)
15
+ check!(worktree)
16
+ plan = { from: worktree.path, to: File.expand_path(destination), force: force? }
17
+ if ui.dry_run?
18
+ return planned(plan,
19
+ "Would move #{shown(plan[:from])} to #{shown(plan[:to])}")
20
+ end
21
+
22
+ move(plan)
23
+ end
24
+
25
+ private
26
+
27
+ def force?
28
+ options[:force] ? true : false
29
+ end
30
+
31
+ def check!(worktree)
32
+ refuse_main!(worktree, 'move')
33
+ if worktree.path == repo.toplevel
34
+ usage_error!('protected_worktree',
35
+ "#{TerminalText.line(worktree.path)} is the current worktree")
36
+ end
37
+ return unless worktree.locked? && !force?
38
+
39
+ usage_error!('protected_worktree',
40
+ "#{TerminalText.line(worktree.path)} is locked (use --force)")
41
+ end
42
+
43
+ def move(plan)
44
+ result = worktrees.move(plan[:from], plan[:to], force: plan[:force])
45
+ git_error!('worktree move', result) unless result.success?
46
+
47
+ ui.report(status: 'done', plan: plan, result: plan.slice(:from, :to),
48
+ text: "Moved #{shown(plan[:from])} to #{shown(plan[:to])}")
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Forgets worktrees whose directory is gone (locked ones stay). Branches are not touched.
6
+ # Takes no snapshot: only git's own bookkeeping for the missing directory is removed.
7
+ class WorktreePrune < WorktreeCommand
8
+ def call
9
+ stale = worktrees.list.select { |worktree| worktree.missing? && !worktree.locked? }
10
+ return noop if stale.empty?
11
+
12
+ prune_or_ask({ worktrees: stale.map(&:path) })
13
+ end
14
+
15
+ private
16
+
17
+ def prune_or_ask(plan)
18
+ return planned(plan, "Would prune:\n#{bullets(plan)}") if ui.dry_run?
19
+
20
+ question = "You are about to prune:\n\n#{bullets(plan)}"
21
+ ui.confirm?(question, plan: plan) ? prune(plan) : cancelled(plan)
22
+ end
23
+
24
+ def prune(plan)
25
+ result = worktrees.prune
26
+ git_error!('worktree prune', result) unless result.success?
27
+
28
+ ui.report(status: 'done', plan: plan, result: { pruned: plan[:worktrees] },
29
+ text: "Pruned:\n#{bullets(plan)}")
30
+ end
31
+
32
+ def noop
33
+ ui.report(status: 'noop', result: { pruned: [] }, text: 'No missing worktrees to prune')
34
+ end
35
+
36
+ def bullets(plan)
37
+ plan[:worktrees].map { |path| "* #{TerminalText.line(path)}" }.join("\n")
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Removes worktrees given as paths or branch names, or picked from a menu.
6
+ # The main checkout, the worktree gitflash runs in, locked and dirty worktrees are refused
7
+ # unless --force is given (the main checkout and the current one never can be removed).
8
+ # Each removal saves a snapshot of the worktree first; `gitflash undo ID` brings the
9
+ # directory, its branch and its uncommitted files back.
10
+ class WorktreeRemove < WorktreeCommand
11
+ include RemovalReport
12
+
13
+ def call(*targets)
14
+ worktrees = Worktrees.new(repo: repo).list
15
+ targets = pick(worktrees) if targets.empty?
16
+ return 0 if targets.nil?
17
+
18
+ chosen = resolve(targets, worktrees)
19
+ remove(chosen)
20
+ end
21
+
22
+ private
23
+
24
+ def pick(worktrees)
25
+ require_interactive!('Pass the worktrees to remove: gitflash wt remove PATH_OR_BRANCH...')
26
+ choices = worktrees.reject { |worktree| worktree.main? || current?(worktree) }
27
+ .to_h { |wt| [menu_label(wt), wt.path] }
28
+ return report_nothing('No worktrees available to remove') if choices.empty?
29
+
30
+ picked = ui.multi_select('Select worktrees to remove', choices)
31
+ picked.empty? ? report_nothing('No worktrees selected') : picked
32
+ end
33
+
34
+ # Paths come from the repository, so control characters in them are shown as escapes
35
+ def menu_label(worktree)
36
+ TerminalText.line("#{worktree.branch || '(detached)'} #{worktree.path}")
37
+ end
38
+
39
+ def force?
40
+ options[:force] ? true : false
41
+ end
42
+
43
+ def current?(worktree)
44
+ worktree.path == repo.toplevel
45
+ end
46
+
47
+ def resolve(targets, worktrees)
48
+ chosen = targets.uniq.map { |target| find!(target, worktrees) }.uniq(&:path)
49
+ refused = chosen.filter_map do |worktree|
50
+ worktree.removal_blocker(force: force?, current: current?(worktree))
51
+ end
52
+ usage_error!('protected_worktree', refused.join('; ')) if refused.any?
53
+ chosen
54
+ end
55
+
56
+ def remove(chosen)
57
+ plan = build_plan(chosen)
58
+ return planned(plan, "Would remove:\n#{preview(chosen, plan)}") if dry_run?
59
+ return cancelled(plan) unless ui.confirm?(summary(chosen, plan), plan: plan)
60
+
61
+ outcomes = chosen.map { |worktree| remove_one(worktree) }
62
+ report_results(plan, outcomes)
63
+ end
64
+
65
+ def dry_run? = ui.dry_run?
66
+
67
+ def preview(chosen, plan) = "#{bullets(chosen)}#{ignored_warning(plan)}"
68
+
69
+ # The plan reported and returned in JSON; `wt clean` adds why each worktree was chosen
70
+ def build_plan(chosen)
71
+ { worktrees: chosen.map { |worktree| plan_row(worktree) }, force: force? }
72
+ end
73
+
74
+ def plan_row(worktree)
75
+ ignored = worktree.missing? ? [] : Worktrees.new(repo: repo).ignored(worktree.path)
76
+ { path: worktree.path, branch: worktree.branch, dirty: worktree.dirty?,
77
+ locked: worktree.locked?, ignored_count: ignored.size, ignored: ignored.first(5) }
78
+ end
79
+
80
+ def remove_one(worktree)
81
+ snapshot = snapshot_for(worktree)
82
+ unsaved = snapshot.skipped_files
83
+ return refuse_unsaved(worktree, unsaved) if unsaved.any?
84
+
85
+ result = Worktrees.new(repo: repo).remove(worktree.path, force: force?)
86
+ Removal.new(worktree: worktree, result: result, snapshot: (snapshot if result.success?))
87
+ end
88
+
89
+ # Files a snapshot cannot hold (untracked and over 50 MB) would be lost for good
90
+ def refuse_unsaved(worktree, files)
91
+ shown = files.first(3).join(', ')
92
+ shown += ", and #{files.size - 3} more" if files.size > 3
93
+ message = "#{files.size} untracked file(s) over 50 MB cannot be saved by a snapshot " \
94
+ "(#{shown}); move or delete them first"
95
+ Removal.new(worktree: worktree, result: Result.new(success: false, output: message),
96
+ snapshot: nil)
97
+ end
98
+
99
+ # A missing directory has no HEAD or files to save; only the branch tip is
100
+ def snapshot_for(worktree)
101
+ reason = "gitflash wt remove #{worktree.path}"
102
+ branches = [worktree.branch].compact
103
+ return take_snapshot(reason, scope: %w[branches], branches: branches) if worktree.missing?
104
+
105
+ take_snapshot(reason, scope: %w[branches head worktree], branches: branches,
106
+ dir: worktree.path)
107
+ end
108
+
109
+ def summary(chosen, plan)
110
+ "You are about to remove the following worktrees:\n\n#{bullets(chosen)}" \
111
+ "#{ignored_warning(plan)}"
112
+ end
113
+
114
+ def bullets(chosen)
115
+ chosen.map { |worktree|
116
+ "* #{TerminalText.line(worktree.path)} (#{worktree.branch || 'detached'})"
117
+ }.join("\n")
118
+ end
119
+
120
+ def report_nothing(text)
121
+ ui.report(status: 'noop', result: { removed: [], failed: [] }, text: text)
122
+ nil
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Unlocks a worktree; see WorktreeLock
6
+ class WorktreeUnlock < WorktreeLock
7
+ private
8
+
9
+ def lock? = false
10
+ end
11
+ end
12
+ end