gitflash 0.5.1 → 0.7.0.beta1

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/README.md +69 -2
  4. data/bin/gitflash +10 -0
  5. data/command_descriptions.yml +99 -0
  6. data/lib/gitflash/branch.rb +14 -2
  7. data/lib/gitflash/cherry_pick.rb +128 -0
  8. data/lib/gitflash/cleanup.rb +70 -0
  9. data/lib/gitflash/cli.rb +72 -21
  10. data/lib/gitflash/command_runner.rb +23 -0
  11. data/lib/gitflash/commands/base.rb +15 -0
  12. data/lib/gitflash/commands/branches.rb +1 -0
  13. data/lib/gitflash/commands/checkout.rb +2 -1
  14. data/lib/gitflash/commands/clean.rb +51 -0
  15. data/lib/gitflash/commands/clean_criteria.rb +29 -0
  16. data/lib/gitflash/commands/delete.rb +23 -8
  17. data/lib/gitflash/commands/gc.rb +41 -0
  18. data/lib/gitflash/commands/hook_install.rb +77 -0
  19. data/lib/gitflash/commands/hook_status.rb +80 -0
  20. data/lib/gitflash/commands/mark.rb +85 -0
  21. data/lib/gitflash/commands/pick.rb +131 -0
  22. data/lib/gitflash/commands/pick_control.rb +67 -0
  23. data/lib/gitflash/commands/pick_outcome.rb +52 -0
  24. data/lib/gitflash/commands/removal_report.rb +65 -0
  25. data/lib/gitflash/commands/reset.rb +7 -1
  26. data/lib/gitflash/commands/snapshot_create.rb +30 -0
  27. data/lib/gitflash/commands/snapshot_list.rb +20 -0
  28. data/lib/gitflash/commands/undo.rb +131 -0
  29. data/lib/gitflash/commands/worktree_add.rb +98 -0
  30. data/lib/gitflash/commands/worktree_clean.rb +49 -0
  31. data/lib/gitflash/commands/worktree_command.rb +28 -0
  32. data/lib/gitflash/commands/worktree_list.rb +48 -0
  33. data/lib/gitflash/commands/worktree_lock.rb +54 -0
  34. data/lib/gitflash/commands/worktree_move.rb +47 -0
  35. data/lib/gitflash/commands/worktree_prune.rb +41 -0
  36. data/lib/gitflash/commands/worktree_remove.rb +119 -0
  37. data/lib/gitflash/commands/worktree_unlock.rb +12 -0
  38. data/lib/gitflash/config.rb +95 -0
  39. data/lib/gitflash/config_keys.rb +19 -0
  40. data/lib/gitflash/git/bash_command.rb +5 -4
  41. data/lib/gitflash/git/in_directory.rb +29 -0
  42. data/lib/gitflash/hook/branch_marker.rb +52 -0
  43. data/lib/gitflash/hook/claude.rb +126 -0
  44. data/lib/gitflash/hook/command_parser.rb +121 -0
  45. data/lib/gitflash/hook/creations.rb +85 -0
  46. data/lib/gitflash/hook/rules.rb +108 -0
  47. data/lib/gitflash/hook/settings.rb +73 -0
  48. data/lib/gitflash/hook_cli.rb +77 -0
  49. data/lib/gitflash/ownership.rb +59 -0
  50. data/lib/gitflash/protection.rb +19 -0
  51. data/lib/gitflash/repo.rb +12 -3
  52. data/lib/gitflash/restore.rb +141 -0
  53. data/lib/gitflash/snapshot.rb +47 -0
  54. data/lib/gitflash/snapshots.rb +70 -0
  55. data/lib/gitflash/state_capture.rb +129 -0
  56. data/lib/gitflash/ui.rb +7 -5
  57. data/lib/gitflash/version.rb +1 -1
  58. data/lib/gitflash/worktree.rb +32 -0
  59. data/lib/gitflash/worktree_cli.rb +115 -0
  60. data/lib/gitflash/worktree_revival.rb +55 -0
  61. data/lib/gitflash/worktrees.rb +147 -0
  62. data/schema/v1.json +1885 -102
  63. metadata +46 -1
@@ -26,6 +26,21 @@ module Gitflash
26
26
  raise Error.new("git #{command} failed:\n#{result.output}", code: 'git_failed')
27
27
  end
28
28
 
29
+ def config
30
+ @config ||= Gitflash::Config.load(root: repo.main_root)
31
+ end
32
+
33
+ def snapshots
34
+ @snapshots ||= Gitflash::Snapshots.new
35
+ end
36
+
37
+ # Saves the state a change is about to modify, so `gitflash undo` can restore it.
38
+ # `dir` saves the HEAD and files of another worktree instead of the current one.
39
+ def take_snapshot(reason, scope:, branches: nil, dir: nil)
40
+ store = dir ? Gitflash::Snapshots.new(bash: Git::InDirectory.new(dir)) : snapshots
41
+ store.create(reason: reason, scope: scope, branches: branches)
42
+ end
43
+
29
44
  def planned(plan, text)
30
45
  ui.report(status: 'planned', plan: plan, text: text)
31
46
  end
@@ -52,6 +52,7 @@ module Gitflash
52
52
  'default' => branch.default?,
53
53
  'merged' => branch.merged && !branch.default?,
54
54
  'gone' => branch.upstream_gone?,
55
+ 'agent' => branch.owner == 'agent',
55
56
  "ahead #{branch.ahead}" => branch.ahead.positive?,
56
57
  "behind #{branch.behind}" => branch.behind.positive?
57
58
  }.select { |_label, shown| shown }.keys.join(', ')
@@ -32,11 +32,12 @@ module Gitflash
32
32
  plan = { branch: name }
33
33
  return planned(plan, "Would check out '#{name}'") if ui.dry_run?
34
34
 
35
+ snapshot = take_snapshot("gitflash checkout #{name}", scope: %w[head])
35
36
  result = repo.checkout(name)
36
37
  git_error!('checkout', result) unless result.success?
37
38
 
38
39
  ui.report(status: 'done', plan: plan, result: { branch: name, previous_branch: current },
39
- text: "Switched to branch '#{name}'")
40
+ undo: snapshot, text: "Switched to branch '#{name}'")
40
41
  end
41
42
 
42
43
  def noop(branch, text)
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Deletes the branches that are no longer needed: merged ones and ones whose upstream is
6
+ # gone by default, plus stale or agent-created ones when asked. Protected branches and
7
+ # branches checked out in a worktree are never selected. Unmerged branches are kept unless
8
+ # --force is given. Plan, confirmation, snapshot and result work as in `delete`; each chosen
9
+ # branch carries the reasons it was chosen.
10
+ class Clean < Delete
11
+ include CleanCriteria
12
+
13
+ def call
14
+ branches = repo.branches(merged_status: true)
15
+ select(branches)
16
+ return nothing_to_clean if @reasons.empty?
17
+
18
+ delete(@reasons.keys, branches.to_h { |branch| [branch.name, branch.sha] })
19
+ end
20
+
21
+ private
22
+
23
+ # Sets @reasons (chosen branch => why) and @skipped (matches kept for being unmerged)
24
+ def select(branches)
25
+ candidates = Cleanup.branches(branches, rules: rules, protection: protection)
26
+ chosen, kept = candidates.partition { |candidate| force? || candidate.item.merged }
27
+ @reasons = chosen.to_h { |candidate| [candidate.item.name, candidate.reasons] }
28
+ @skipped = kept.map { |candidate| skipped_row(candidate) }
29
+ end
30
+
31
+ def build_plan(names)
32
+ super.merge(reasons: @reasons.slice(*names), skipped: @skipped)
33
+ end
34
+
35
+ def skipped_row(candidate)
36
+ { branch: candidate.item.name, reason: 'has unmerged changes (use --force)' }
37
+ end
38
+
39
+ def nothing_to_clean
40
+ plan = { branches: [], force: force?, reasons: {}, skipped: @skipped }
41
+ ui.report(status: 'noop', plan: plan, result: { deleted: [], failed: [] },
42
+ text: @skipped.empty? ? 'Nothing to clean' : skipped_text)
43
+ end
44
+
45
+ def skipped_text
46
+ lines = @skipped.map { |row| "Kept #{row[:branch]}: #{row[:reason]}" }
47
+ (['Nothing to clean.'] + lines).join("\n")
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # The selection options shared by `clean` and `wt clean`: --merged, --gone, --stale [DAYS]
6
+ # and --agent. Without any of them merged and gone branches are selected.
7
+ module CleanCriteria
8
+ FLAGS = %i[merged gone stale agent].freeze
9
+
10
+ private
11
+
12
+ def rules
13
+ given = FLAGS.select { |flag| options[flag] }
14
+ Cleanup::Rules.new(criteria: given.empty? ? Cleanup::DEFAULT_CRITERIA : given,
15
+ stale_days: stale_days, now: Time.now)
16
+ end
17
+
18
+ def stale_days
19
+ value = options[:stale]
20
+ return config.stale_days if value.nil? || value.to_s.empty?
21
+
22
+ days = Integer(value, exception: false)
23
+ return days if days&.positive?
24
+
25
+ usage_error!('invalid_options', "--stale needs a positive number of days, got '#{value}'")
26
+ end
27
+ end
28
+ end
29
+ end
@@ -6,8 +6,6 @@ module Gitflash
6
6
  # Uses `git branch -d` (merged branches only) unless --force is given.
7
7
  # The result lists each deleted branch with its last commit, so it can be recreated.
8
8
  class Delete < Base
9
- PROTECTED_NAMES = %w[main master].freeze
10
-
11
9
  def call(*names)
12
10
  branches = repo.branches
13
11
  candidates = branches.reject { |branch| protected?(branch) }.map(&:name)
@@ -34,8 +32,16 @@ module Gitflash
34
32
  options[:force] ? true : false
35
33
  end
36
34
 
35
+ def protection
36
+ @protection ||= Protection.new(config: config, checked_out: checked_out)
37
+ end
38
+
39
+ def checked_out
40
+ Worktrees.new(repo: repo).list.filter_map(&:branch)
41
+ end
42
+
37
43
  def protected?(branch)
38
- branch.current? || branch.default? || PROTECTED_NAMES.include?(branch.name)
44
+ protection.protected?(branch)
39
45
  end
40
46
 
41
47
  def validate!(names, branches)
@@ -48,16 +54,24 @@ module Gitflash
48
54
 
49
55
  usage_error!('protected_branch',
50
56
  "Refusing to delete protected branches: #{refused.join(', ')} " \
51
- '(current, default, main and master are protected)')
57
+ '(current, default, main, master, `protected` in .gitflash.yml, ' \
58
+ 'checked out in a worktree)')
52
59
  end
53
60
 
54
61
  def delete(names, shas)
55
- plan = { branches: names, force: force? }
62
+ plan = build_plan(names)
56
63
  return planned(plan, "Would delete:\n#{bullets(names)}") if ui.dry_run?
57
64
  return cancelled(plan) unless ui.confirm?(summary(names), plan: plan)
58
65
 
66
+ snapshot = take_snapshot("gitflash delete #{names.join(' ')}",
67
+ scope: %w[branches], branches: names)
59
68
  outcomes = names.map { |name| [name, repo.delete_branch(name, force: force?)] }
60
- report_results(plan, outcomes, shas)
69
+ report_results(plan, outcomes, shas, snapshot)
70
+ end
71
+
72
+ # The plan reported and returned in JSON; `clean` adds why each branch was chosen
73
+ def build_plan(names)
74
+ { branches: names, force: force? }
61
75
  end
62
76
 
63
77
  def summary(names)
@@ -74,14 +88,15 @@ module Gitflash
74
88
  nil
75
89
  end
76
90
 
77
- def report_results(plan, outcomes, shas)
91
+ def report_results(plan, outcomes, shas, snapshot)
78
92
  deleted, failed = outcomes.partition { |_name, result| result.success? }
79
93
  result = {
80
94
  deleted: deleted.map { |name, _result| { branch: name, sha: shas[name] } },
81
95
  failed: failed.map { |name, result| { branch: name, error: result.output } }
82
96
  }
83
97
  ui.report(status: failed.empty? ? 'done' : 'failed', plan: plan, result: result,
84
- error: failure(failed, outcomes), text: results_text(result))
98
+ error: failure(failed, outcomes), text: results_text(result),
99
+ undo: (snapshot if deleted.any?))
85
100
  end
86
101
 
87
102
  def failure(failed, outcomes)
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Deletes snapshots older than --older-than DAYS (default 30). Git frees their objects at
6
+ # its next garbage collection.
7
+ class Gc < Base
8
+ DEFAULT_DAYS = 30
9
+
10
+ def call
11
+ days = options[:older_than] || DEFAULT_DAYS
12
+ old = older_than(days)
13
+ plan = { older_than_days: days, snapshots: old.map(&:id) }
14
+ return nothing(plan) if old.empty?
15
+
16
+ delete(plan, "#{old.size} snapshots older than #{days} days")
17
+ end
18
+
19
+ private
20
+
21
+ def delete(plan, text)
22
+ return planned(plan, "Would delete #{text}") if ui.dry_run?
23
+ return cancelled(plan) unless ui.confirm?("You are about to delete #{text}", plan: plan)
24
+
25
+ plan[:snapshots].each { |id| snapshots.delete(id) }
26
+ ui.report(status: 'done', plan: plan, result: { deleted: plan[:snapshots] },
27
+ text: "Deleted #{text}")
28
+ end
29
+
30
+ def older_than(days)
31
+ cutoff = Time.now - (days * 86_400)
32
+ snapshots.list.select { |snapshot| Time.iso8601(snapshot.created_at) < cutoff }
33
+ end
34
+
35
+ def nothing(plan)
36
+ ui.report(status: 'noop', plan: plan, result: { deleted: [] },
37
+ text: "No snapshots older than #{plan[:older_than_days]} days")
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module Gitflash
6
+ module Commands
7
+ # Registers `gitflash hook claude` as a Claude Code hook for Bash commands: PreToolUse
8
+ # (snapshots before destructive git commands) and PostToolUse (marks branches an agent
9
+ # created). Merges into the settings file and keeps everything else in it.
10
+ class HookInstall < Base
11
+ SCOPES = Hook::Settings::SCOPES
12
+ MARKING_COMMAND = 'gitflash hook claude'
13
+
14
+ def call
15
+ plan = build_plan
16
+ data = settings.read(plan[:file])
17
+ entry = settings.entry(data)
18
+ marking = settings.entry(data, Hook::Claude::POST_EVENT)
19
+ return already_installed(plan) if up_to_date?(entry, marking, plan)
20
+ return planned(plan, "Would add the gitflash hook to #{plan[:file]}") if ui.dry_run?
21
+
22
+ install(data, entry, marking, plan)
23
+ end
24
+
25
+ private
26
+
27
+ def up_to_date?(entry, marking, plan)
28
+ entry && entry['command'] == plan[:command] && marking
29
+ end
30
+
31
+ def build_plan
32
+ 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) }
35
+ end
36
+
37
+ def settings
38
+ @settings ||= Hook::Settings.new(options[:scope] == 'user' ? nil : repo)
39
+ end
40
+
41
+ def install(data, entry, marking, plan)
42
+ entry ? entry['command'] = plan[:command] : add_entry(data, 'PreToolUse', plan[:command])
43
+ 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")
46
+ ui.report(status: 'done', plan: plan, result: plan,
47
+ text: "Installed the gitflash hook in #{plan[:file]}.\n" \
48
+ "It applies to #{plan[:applies_to]}, including running ones.")
49
+ end
50
+
51
+ def choice(name, allowed, default)
52
+ value = options[name] || default
53
+ return value if allowed.include?(value)
54
+
55
+ usage_error!('invalid_options', "Unknown #{name} '#{value}' (use #{allowed.join(', ')})")
56
+ end
57
+
58
+ def hook_command
59
+ mode = choice(:mode, Hook::Claude::MODES, 'snapshot')
60
+ mode == 'snapshot' ? 'gitflash hook claude' : "gitflash hook claude --mode #{mode}"
61
+ end
62
+
63
+ def already_installed(plan)
64
+ ui.report(status: 'noop', plan: plan, result: plan,
65
+ text: "Already installed in #{plan[:file]}.\nIt applies to #{plan[:applies_to]}.")
66
+ end
67
+
68
+ 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
+ }
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Shows whether the gitflash hook protects Claude Code sessions in the current directory:
6
+ # which settings files hold it, which sessions each file applies to, and whether the
7
+ # gitflash executable the hook runs can be found.
8
+ class HookStatus < Base
9
+ def call
10
+ settings = Hook::Settings.new(inside_repo? ? repo : nil)
11
+ files = settings.scopes.map { |scope| file_status(settings, scope) }
12
+ result = summary(files, find_executable)
13
+ ui.report(status: 'done', result: result, text: text(result))
14
+ end
15
+
16
+ private
17
+
18
+ def inside_repo?
19
+ repo.ensure_work_tree!
20
+ true
21
+ rescue Error
22
+ false
23
+ end
24
+
25
+ def summary(files, executable)
26
+ installed = files.select { |file| file[:installed] }
27
+ active = executable && installed.any?
28
+ { active: active ? true : false, marking: active && installed.any? { |f| f[:marking] },
29
+ executable: executable, files: files }
30
+ end
31
+
32
+ def file_status(settings, scope)
33
+ path = settings.path(scope)
34
+ data = settings.read(path)
35
+ entry = settings.entry(data)
36
+ { scope: scope, file: path, installed: !entry.nil?, command: entry&.fetch('command', nil),
37
+ marking: !settings.entry(data, Hook::Claude::POST_EVENT).nil?,
38
+ applies_to: settings.applies_to(scope) }
39
+ end
40
+
41
+ def find_executable
42
+ ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).map { |dir| File.join(dir, 'gitflash') }
43
+ .find { |path| File.file?(path) && File.executable?(path) }
44
+ end
45
+
46
+ def text(result)
47
+ lines = [headline(result)]
48
+ result[:files].each do |file|
49
+ state = file[:installed] ? installed_text(file) : 'not installed'
50
+ lines << " #{file[:scope].ljust(8)} #{file[:file]}: #{state}"
51
+ lines << " applies to #{file[:applies_to]}" if file[:installed]
52
+ end
53
+ lines << " gitflash executable: #{result[:executable] || 'not found on PATH'}"
54
+ lines.join("\n")
55
+ end
56
+
57
+ def installed_text(file)
58
+ note = file[:marking] ? ', marks agent branches' : ', branch marking off'
59
+ "installed (#{file[:command]})#{note}"
60
+ end
61
+
62
+ def headline(result)
63
+ return active_headline(result) if result[:active]
64
+
65
+ reason = if result[:executable]
66
+ 'run `gitflash hook install`'
67
+ else
68
+ 'the gitflash executable is not on PATH'
69
+ end
70
+ "gitflash hook: not active (#{reason})"
71
+ end
72
+
73
+ def active_headline(result)
74
+ return 'gitflash hook: active, marks agent branches' if result[:marking]
75
+
76
+ 'gitflash hook: active (branch marking off: run `gitflash hook install` again)'
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
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.
7
+ # Without branch names the current branch is marked. Needs no confirmation: only a git
8
+ # config key changes, and `--clear` or another `mark` reverses it.
9
+ class Mark < Base
10
+ def call(*names)
11
+ names = names.uniq
12
+ names = [current_branch!] if names.empty?
13
+ check_branches!(names)
14
+
15
+ previous = ownership.all
16
+ plan = { branches: names, owner: owner }
17
+ return planned(plan, "Would #{verb} #{names.join(', ')}") if ui.dry_run?
18
+
19
+ apply(plan, previous)
20
+ end
21
+
22
+ private
23
+
24
+ def ownership
25
+ @ownership ||= Ownership.new
26
+ end
27
+
28
+ # The owner to set, nil for --clear
29
+ def owner
30
+ given = options[:owner]
31
+ if options[:clear]
32
+ usage_error!('invalid_options', 'Use either --owner or --clear, not both') if given
33
+ return nil
34
+ end
35
+ return 'agent' if given.nil?
36
+ return given if Ownership::OWNERS.include?(given)
37
+
38
+ usage_error!('invalid_options', "Unknown owner '#{given}' (use agent or human)")
39
+ end
40
+
41
+ def verb
42
+ options[:clear] ? 'clear the mark of' : "mark as #{owner}"
43
+ end
44
+
45
+ def current_branch!
46
+ repo.current_branch || usage_error!('invalid_usage',
47
+ 'HEAD is detached: pass the branch to mark')
48
+ end
49
+
50
+ def check_branches!(names)
51
+ known = repo.branches.map(&:name)
52
+ unknown = names.reject { |name| known.include?(name) }
53
+ usage_error!('unknown_branch', "Unknown branch: #{unknown.join(', ')}") if unknown.any?
54
+ end
55
+
56
+ def apply(plan, previous)
57
+ plan[:branches].each { |name| write(name, plan[:owner]) }
58
+ rows = plan[:branches].map { |name| row(name, plan[:owner], previous[name]) }
59
+ status = rows.all? { |entry| entry[:owner] == entry[:previous] } ? 'noop' : 'done'
60
+ ui.report(status: status, plan: plan, result: { branches: rows }, text: text(rows))
61
+ end
62
+
63
+ def write(name, owner)
64
+ owner ? ownership.mark(name, owner) : ownership.clear(name)
65
+ end
66
+
67
+ def row(name, owner, previous)
68
+ { branch: name, owner: owner, previous: previous }
69
+ end
70
+
71
+ def text(rows)
72
+ rows.map { |row| line(row) }.join("\n")
73
+ end
74
+
75
+ def line(row)
76
+ if row[:owner] == row[:previous]
77
+ return "#{row[:branch]} is already #{row[:owner] || 'unmarked'}"
78
+ end
79
+ return "Cleared the mark of #{row[:branch]} (was #{row[:previous]})" unless row[:owner]
80
+
81
+ "Marked #{row[:branch]} as #{row[:owner]}"
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Cherry-picks commits from another branch. `--list` shows the commits of SOURCE whose change
6
+ # is not on the current branch yet (commits with an equivalent patch already here are marked
7
+ # `applied` and never picked). With SHAs, or chosen from a menu in a terminal, it applies them
8
+ # oldest first with `git cherry-pick -x` after saving a snapshot; `gitflash undo` returns to
9
+ # the starting point. On a conflict it stops and lists the files: see PickControl.
10
+ class Pick < Base
11
+ include PickOutcome
12
+
13
+ NOTHING = 'Nothing to pick: the changes are already on the current branch'
14
+
15
+ # --continue, --skip and --abort are handled by PickControl, everything else by Pick
16
+ def self.command_for(options)
17
+ PickControl::ACTIONS.any? { |action| options[action.to_sym] } ? PickControl : self
18
+ end
19
+
20
+ def call(source = nil, *shas)
21
+ usage_error!('input_required', 'Pass the source branch: gitflash pick SOURCE') unless source
22
+ check_idle!
23
+ candidates = cherry_pick.candidates(source_ref(source))
24
+ return list(source, candidates) if options[:list]
25
+
26
+ wanted = shas.empty? ? pick_from_menu(candidates) : shas
27
+ return 0 if wanted.nil?
28
+
29
+ pick(source, select_candidates(source, candidates, wanted))
30
+ end
31
+
32
+ private
33
+
34
+ def check_idle!
35
+ return unless cherry_pick.in_progress?
36
+
37
+ usage_error!('invalid_usage', 'A cherry-pick is already in progress: use gitflash pick ' \
38
+ '--continue, --skip or --abort')
39
+ end
40
+
41
+ def source_ref(source)
42
+ ref = cherry_pick.source_ref(source)
43
+ ref || usage_error!('unknown_branch', "Unknown branch '#{source}'")
44
+ end
45
+
46
+ def list(source, candidates)
47
+ ui.report(status: 'done', result: { source: source, commits: candidates.map(&:to_h) },
48
+ text: table(candidates))
49
+ end
50
+
51
+ def table(candidates)
52
+ return 'No commits to pick.' if candidates.empty?
53
+
54
+ candidates.map { |candidate| row(candidate) }.join("\n")
55
+ end
56
+
57
+ def row(candidate)
58
+ stat = "+#{candidate.insertions} -#{candidate.deletions}"
59
+ "#{candidate.applied ? '=' : ' '} #{candidate.short_sha} " \
60
+ "#{TerminalText.safe(candidate.subject)} (#{TerminalText.safe(candidate.author)}, " \
61
+ "#{candidate.date[0, 10]}, #{candidate.files} files #{stat})"
62
+ end
63
+
64
+ # SHAs picked from a menu, nil after telling the user there is nothing to pick
65
+ def pick_from_menu(candidates)
66
+ require_interactive!('Pass the commits to pick: gitflash pick SOURCE SHA...')
67
+ fresh = candidates.reject(&:applied)
68
+ return nothing_chosen('No commits to pick') if fresh.empty?
69
+
70
+ chosen = ui.multi_select('Select commits to pick (oldest first)',
71
+ fresh.to_h { |candidate| [row(candidate), candidate.sha] })
72
+ chosen.empty? ? nothing_chosen('No commits selected') : chosen
73
+ end
74
+
75
+ def nothing_chosen(text)
76
+ report_nothing(text)
77
+ nil
78
+ end
79
+
80
+ # The asked-for commits in source order (oldest first), whatever order they were given in
81
+ def select_candidates(source, candidates, wanted)
82
+ found = wanted.map { |arg| find(arg, candidates, source) }
83
+ candidates.select { |candidate| found.include?(candidate) }
84
+ end
85
+
86
+ def find(arg, candidates, source)
87
+ matches = candidates.select { |candidate| candidate.sha.start_with?(arg.to_s) }
88
+ return matches.first if arg.to_s.length >= 4 && matches.size == 1
89
+
90
+ usage_error!('unknown_commit', "'#{arg}' is not a commit of #{source} that is missing " \
91
+ 'from the current branch (or it is ambiguous)')
92
+ end
93
+
94
+ def pick(source, chosen)
95
+ fresh, applied = chosen.partition { |candidate| !candidate.applied }
96
+ return report_nothing(NOTHING) if fresh.empty?
97
+
98
+ plan = build_plan(source, fresh, applied)
99
+ return planned(plan, "Would pick:\n#{bullets(plan)}") if ui.dry_run?
100
+ return cancelled(plan) unless confirm?(plan)
101
+
102
+ run(plan)
103
+ end
104
+
105
+ def build_plan(source, fresh, applied)
106
+ skipped = applied.map { |c| { sha: c.sha, reason: 'already on the current branch' } }
107
+ { source: source, commits: fresh.map(&:sha), skipped: skipped,
108
+ commit: options[:commit] != false }
109
+ end
110
+
111
+ def confirm?(plan)
112
+ ui.confirm?("You are about to pick:\n\n#{bullets(plan)}", plan: plan)
113
+ end
114
+
115
+ def run(plan)
116
+ snapshot = take_snapshot("gitflash pick #{plan[:source]}",
117
+ scope: %w[branches head worktree], branches: current_branch_names)
118
+ result = cherry_pick.apply(plan[:commits], commit: plan[:commit])
119
+ outcome(plan, result, plan[:commits], snapshot)
120
+ end
121
+
122
+ def bullets(plan)
123
+ plan[:commits].map { |sha| "* #{sha[0, 7]}" }.join("\n")
124
+ end
125
+
126
+ def report_nothing(text)
127
+ ui.report(status: 'noop', result: { applied: [] }, text: text)
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # `gitflash pick --continue`, `--skip` or `--abort`: wraps git's own cherry-pick controls for
6
+ # a pick that stopped on conflicts. --skip and --abort discard work in progress, so they ask
7
+ # for confirmation and save a snapshot first; --continue adds to the work and does not.
8
+ class PickControl < Base
9
+ include PickOutcome
10
+
11
+ ACTIONS = %w[continue skip abort].freeze
12
+
13
+ def call(*)
14
+ action = chosen_action
15
+ usage_error!('invalid_usage', 'No cherry-pick in progress') unless cherry_pick.in_progress?
16
+
17
+ plan = build_plan(action)
18
+ return perform(action, plan, nil) if action == 'continue'
19
+ return planned(plan, "Would #{action} the cherry-pick in progress") if ui.dry_run?
20
+ return cancelled(plan) unless ui.confirm?(summary(action), plan: plan)
21
+
22
+ perform(action, plan, take_snapshot("gitflash pick --#{action}", scope: %w[head worktree]))
23
+ end
24
+
25
+ private
26
+
27
+ def chosen_action
28
+ given = ACTIONS.select { |action| options[action.to_sym] }
29
+ return given.first if given.one?
30
+
31
+ usage_error!('invalid_options', 'Use exactly one of --continue, --skip or --abort')
32
+ end
33
+
34
+ def build_plan(action)
35
+ progress = cherry_pick.progress
36
+ { action: action, current: progress.current, conflicted: progress.conflicted,
37
+ remaining: progress.remaining }
38
+ end
39
+
40
+ def summary(action)
41
+ "You are about to #{action} the cherry-pick in progress; " \
42
+ 'the conflict resolution made so far is discarded.'
43
+ end
44
+
45
+ def perform(action, plan, snapshot)
46
+ result = cherry_pick.public_send(action == 'continue' ? :continue : action.to_sym)
47
+ return outcome_after(action, plan, result, snapshot) if action != 'abort'
48
+
49
+ git_error!('cherry-pick --abort', result) unless result.success?
50
+ finish(action, plan, snapshot)
51
+ end
52
+
53
+ def outcome_after(action, plan, result, snapshot)
54
+ return conflict(plan, result, [], snapshot) if cherry_pick.in_progress?
55
+
56
+ git_error!("cherry-pick --#{action}", result) unless result.success?
57
+ finish(action, plan, snapshot)
58
+ end
59
+
60
+ def finish(action, plan, snapshot)
61
+ head = repo.resolve_commit('HEAD')
62
+ ui.report(status: 'done', plan: plan, result: { action: action, head: head },
63
+ undo: snapshot, text: "Cherry-pick #{action} done, now at #{head[0, 7]}")
64
+ end
65
+ end
66
+ end
67
+ end