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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -0
- data/README.md +69 -2
- data/bin/gitflash +10 -0
- data/command_descriptions.yml +99 -0
- data/lib/gitflash/branch.rb +14 -2
- data/lib/gitflash/cherry_pick.rb +128 -0
- data/lib/gitflash/cleanup.rb +70 -0
- data/lib/gitflash/cli.rb +72 -21
- data/lib/gitflash/command_runner.rb +23 -0
- data/lib/gitflash/commands/base.rb +15 -0
- data/lib/gitflash/commands/branches.rb +1 -0
- data/lib/gitflash/commands/checkout.rb +2 -1
- data/lib/gitflash/commands/clean.rb +51 -0
- data/lib/gitflash/commands/clean_criteria.rb +29 -0
- data/lib/gitflash/commands/delete.rb +23 -8
- data/lib/gitflash/commands/gc.rb +41 -0
- data/lib/gitflash/commands/hook_install.rb +77 -0
- data/lib/gitflash/commands/hook_status.rb +80 -0
- data/lib/gitflash/commands/mark.rb +85 -0
- data/lib/gitflash/commands/pick.rb +131 -0
- data/lib/gitflash/commands/pick_control.rb +67 -0
- data/lib/gitflash/commands/pick_outcome.rb +52 -0
- data/lib/gitflash/commands/removal_report.rb +65 -0
- data/lib/gitflash/commands/reset.rb +7 -1
- data/lib/gitflash/commands/snapshot_create.rb +30 -0
- data/lib/gitflash/commands/snapshot_list.rb +20 -0
- data/lib/gitflash/commands/undo.rb +131 -0
- data/lib/gitflash/commands/worktree_add.rb +98 -0
- data/lib/gitflash/commands/worktree_clean.rb +49 -0
- data/lib/gitflash/commands/worktree_command.rb +28 -0
- data/lib/gitflash/commands/worktree_list.rb +48 -0
- data/lib/gitflash/commands/worktree_lock.rb +54 -0
- data/lib/gitflash/commands/worktree_move.rb +47 -0
- data/lib/gitflash/commands/worktree_prune.rb +41 -0
- data/lib/gitflash/commands/worktree_remove.rb +119 -0
- data/lib/gitflash/commands/worktree_unlock.rb +12 -0
- data/lib/gitflash/config.rb +95 -0
- data/lib/gitflash/config_keys.rb +19 -0
- data/lib/gitflash/git/bash_command.rb +5 -4
- data/lib/gitflash/git/in_directory.rb +29 -0
- data/lib/gitflash/hook/branch_marker.rb +52 -0
- data/lib/gitflash/hook/claude.rb +126 -0
- data/lib/gitflash/hook/command_parser.rb +121 -0
- data/lib/gitflash/hook/creations.rb +85 -0
- data/lib/gitflash/hook/rules.rb +108 -0
- data/lib/gitflash/hook/settings.rb +73 -0
- data/lib/gitflash/hook_cli.rb +77 -0
- data/lib/gitflash/ownership.rb +59 -0
- data/lib/gitflash/protection.rb +19 -0
- data/lib/gitflash/repo.rb +12 -3
- data/lib/gitflash/restore.rb +141 -0
- data/lib/gitflash/snapshot.rb +47 -0
- data/lib/gitflash/snapshots.rb +70 -0
- data/lib/gitflash/state_capture.rb +129 -0
- data/lib/gitflash/ui.rb +7 -5
- data/lib/gitflash/version.rb +1 -1
- data/lib/gitflash/worktree.rb +32 -0
- data/lib/gitflash/worktree_cli.rb +115 -0
- data/lib/gitflash/worktree_revival.rb +55 -0
- data/lib/gitflash/worktrees.rb +147 -0
- data/schema/v1.json +1885 -102
- 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
|
-
|
|
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
|
|
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 =
|
|
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
|