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
|
@@ -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| "* #{path}" }.join("\n")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
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| ["#{wt.branch || '(detached)'} #{wt.path}", 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
|
+
def force?
|
|
35
|
+
options[:force] ? true : false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def current?(worktree)
|
|
39
|
+
worktree.path == repo.toplevel
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def resolve(targets, worktrees)
|
|
43
|
+
chosen = targets.uniq.map { |target| find!(target, worktrees) }.uniq(&:path)
|
|
44
|
+
refused = chosen.filter_map do |worktree|
|
|
45
|
+
worktree.removal_blocker(force: force?, current: current?(worktree))
|
|
46
|
+
end
|
|
47
|
+
usage_error!('protected_worktree', refused.join('; ')) if refused.any?
|
|
48
|
+
chosen
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def remove(chosen)
|
|
52
|
+
plan = build_plan(chosen)
|
|
53
|
+
return planned(plan, "Would remove:\n#{preview(chosen, plan)}") if dry_run?
|
|
54
|
+
return cancelled(plan) unless ui.confirm?(summary(chosen, plan), plan: plan)
|
|
55
|
+
|
|
56
|
+
outcomes = chosen.map { |worktree| remove_one(worktree) }
|
|
57
|
+
report_results(plan, outcomes)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def dry_run? = ui.dry_run?
|
|
61
|
+
|
|
62
|
+
def preview(chosen, plan) = "#{bullets(chosen)}#{ignored_warning(plan)}"
|
|
63
|
+
|
|
64
|
+
# The plan reported and returned in JSON; `wt clean` adds why each worktree was chosen
|
|
65
|
+
def build_plan(chosen)
|
|
66
|
+
{ worktrees: chosen.map { |worktree| plan_row(worktree) }, force: force? }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def plan_row(worktree)
|
|
70
|
+
ignored = worktree.missing? ? [] : Worktrees.new(repo: repo).ignored(worktree.path)
|
|
71
|
+
{ path: worktree.path, branch: worktree.branch, dirty: worktree.dirty?,
|
|
72
|
+
locked: worktree.locked?, ignored_count: ignored.size, ignored: ignored.first(5) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def remove_one(worktree)
|
|
76
|
+
snapshot = snapshot_for(worktree)
|
|
77
|
+
unsaved = snapshot.skipped_files
|
|
78
|
+
return refuse_unsaved(worktree, unsaved) if unsaved.any?
|
|
79
|
+
|
|
80
|
+
result = Worktrees.new(repo: repo).remove(worktree.path, force: force?)
|
|
81
|
+
Removal.new(worktree: worktree, result: result, snapshot: (snapshot if result.success?))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Files a snapshot cannot hold (untracked and over 50 MB) would be lost for good
|
|
85
|
+
def refuse_unsaved(worktree, files)
|
|
86
|
+
shown = files.first(3).join(', ')
|
|
87
|
+
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"
|
|
90
|
+
Removal.new(worktree: worktree, result: Result.new(success: false, output: message),
|
|
91
|
+
snapshot: nil)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# A missing directory has no HEAD or files to save; only the branch tip is
|
|
95
|
+
def snapshot_for(worktree)
|
|
96
|
+
reason = "gitflash wt remove #{worktree.path}"
|
|
97
|
+
branches = [worktree.branch].compact
|
|
98
|
+
return take_snapshot(reason, scope: %w[branches], branches: branches) if worktree.missing?
|
|
99
|
+
|
|
100
|
+
take_snapshot(reason, scope: %w[branches head worktree], branches: branches,
|
|
101
|
+
dir: worktree.path)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def summary(chosen, plan)
|
|
105
|
+
"You are about to remove the following worktrees:\n\n#{bullets(chosen)}" \
|
|
106
|
+
"#{ignored_warning(plan)}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def bullets(chosen)
|
|
110
|
+
chosen.map { |worktree| "* #{worktree.path} (#{worktree.branch || 'detached'})" }.join("\n")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def report_nothing(text)
|
|
114
|
+
ui.report(status: 'noop', result: { removed: [], failed: [] }, text: text)
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
module Gitflash
|
|
6
|
+
# Settings from `.gitflash.yml` in the main checkout and `~/.config/gitflash.yml`.
|
|
7
|
+
# Precedence: repository file, then user file, then built-in defaults.
|
|
8
|
+
# Command-line flags are applied by the commands themselves and win over all of these.
|
|
9
|
+
# An unknown key or a value of the wrong type raises a UsageError, so a typo never goes unnoticed.
|
|
10
|
+
Config = Data.define(:protected_patterns, :stale_days, :worktree_dir) do
|
|
11
|
+
# `root` is the main checkout; `home` is where the user file lives
|
|
12
|
+
def self.load(root:, home: Dir.home)
|
|
13
|
+
values = [File.join(home, ConfigKeys::USER_FILE), File.join(root, ConfigKeys::REPO_FILE)]
|
|
14
|
+
.map { |path| read(path) }
|
|
15
|
+
.reduce(ConfigKeys::DEFAULTS) { |merged, layer| merged.merge(layer) }
|
|
16
|
+
new(protected_patterns: values['protected'], stale_days: values['stale_days'],
|
|
17
|
+
worktree_dir: values['worktree_dir'])
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.defaults
|
|
21
|
+
new(protected_patterns: [], stale_days: 30, worktree_dir: ConfigKeys::DEFAULT_WORKTREE_DIR)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.read(path)
|
|
25
|
+
return {} unless File.file?(path)
|
|
26
|
+
|
|
27
|
+
reject_duplicate_keys!(path)
|
|
28
|
+
data = YAML.safe_load_file(path) || {}
|
|
29
|
+
validate!(data, path)
|
|
30
|
+
data
|
|
31
|
+
rescue Psych::Exception, SystemCallError, IOError => e
|
|
32
|
+
raise invalid(path, "cannot be read (#{e.message.lines.first.strip})")
|
|
33
|
+
end
|
|
34
|
+
private_class_method :read
|
|
35
|
+
|
|
36
|
+
# A YAML loader keeps only the last of two equal keys, which could drop a `protected` list
|
|
37
|
+
def self.reject_duplicate_keys!(path)
|
|
38
|
+
root = Psych.parse_file(path)
|
|
39
|
+
return unless root
|
|
40
|
+
|
|
41
|
+
duplicate = duplicate_key(root)
|
|
42
|
+
raise invalid(path, "has the key '#{duplicate}' more than once") if duplicate
|
|
43
|
+
end
|
|
44
|
+
private_class_method :reject_duplicate_keys!
|
|
45
|
+
|
|
46
|
+
def self.duplicate_key(node)
|
|
47
|
+
own = node.is_a?(Psych::Nodes::Mapping) ? own_duplicate(node) : nil
|
|
48
|
+
own || (node.children || []).filter_map { |child| duplicate_key(child) }.first
|
|
49
|
+
end
|
|
50
|
+
private_class_method :duplicate_key
|
|
51
|
+
|
|
52
|
+
def self.own_duplicate(mapping)
|
|
53
|
+
keys = mapping.children.each_slice(2).filter_map do |key, _value|
|
|
54
|
+
key.value if key.respond_to?(:value)
|
|
55
|
+
end
|
|
56
|
+
keys.tally.find { |_key, count| count > 1 }&.first
|
|
57
|
+
end
|
|
58
|
+
private_class_method :own_duplicate
|
|
59
|
+
|
|
60
|
+
def self.validate!(data, path)
|
|
61
|
+
raise invalid(path, 'must be a mapping') unless data.is_a?(Hash)
|
|
62
|
+
|
|
63
|
+
unknown = data.keys - ConfigKeys::DEFAULTS.keys
|
|
64
|
+
raise invalid(path, "has unknown keys: #{unknown.join(', ')}") if unknown.any?
|
|
65
|
+
|
|
66
|
+
check_types!(data, path)
|
|
67
|
+
end
|
|
68
|
+
private_class_method :validate!
|
|
69
|
+
|
|
70
|
+
def self.check_types!(data, path)
|
|
71
|
+
data.each do |key, value|
|
|
72
|
+
check, expected = ConfigKeys::CHECKS.fetch(key)
|
|
73
|
+
raise invalid(path, "'#{key}' must be #{expected}") unless check.call(value)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
private_class_method :check_types!
|
|
77
|
+
|
|
78
|
+
def self.invalid(path, problem)
|
|
79
|
+
UsageError.new("#{path} #{problem}", code: 'invalid_config')
|
|
80
|
+
end
|
|
81
|
+
private_class_method :invalid
|
|
82
|
+
|
|
83
|
+
# True when the branch name matches a configured pattern (shell-style `*` and `?`)
|
|
84
|
+
def protected?(name)
|
|
85
|
+
protected_patterns.any? { |pattern| File.fnmatch?(pattern, name) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Directory for a new worktree. `%{repo}` and `%{branch}` are replaced; a relative path
|
|
89
|
+
# is resolved against the main checkout. Slashes in a branch name stay as folders.
|
|
90
|
+
def worktree_path(root:, branch:)
|
|
91
|
+
path = format(worktree_dir, repo: File.basename(root), branch: branch)
|
|
92
|
+
File.expand_path(path, root)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitflash
|
|
4
|
+
# Built-in defaults, files and the check each key must pass
|
|
5
|
+
module ConfigKeys
|
|
6
|
+
REPO_FILE = '.gitflash.yml'
|
|
7
|
+
USER_FILE = File.join('.config', 'gitflash.yml')
|
|
8
|
+
DEFAULT_WORKTREE_DIR = '../%<repo>s.worktrees/%<branch>s'
|
|
9
|
+
DEFAULTS = {
|
|
10
|
+
'protected' => [], 'stale_days' => 30, 'worktree_dir' => DEFAULT_WORKTREE_DIR
|
|
11
|
+
}.freeze
|
|
12
|
+
CHECKS = {
|
|
13
|
+
'protected' => [->(v) { v.is_a?(Array) && v.all?(String) },
|
|
14
|
+
'a list of branch name patterns'],
|
|
15
|
+
'stale_days' => [->(v) { v.is_a?(Integer) && v.positive? }, 'a positive whole number'],
|
|
16
|
+
'worktree_dir' => [->(v) { v.is_a?(String) && !v.empty? }, 'a non-empty string']
|
|
17
|
+
}.freeze
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -8,16 +8,17 @@ module Gitflash
|
|
|
8
8
|
class << self
|
|
9
9
|
# Runs a command without a shell and returns its stdout.
|
|
10
10
|
# Raises CommandError when the command exits with a non-zero status.
|
|
11
|
-
def exec(*args)
|
|
12
|
-
stdout, stderr, success = capture(*args)
|
|
11
|
+
def exec(*args, env: {})
|
|
12
|
+
stdout, stderr, success = capture(*args, env: env)
|
|
13
13
|
raise CommandError, "#{args.join(' ')} failed: #{stderr.strip}" unless success
|
|
14
14
|
|
|
15
15
|
stdout
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
# Runs a command without a shell and returns [stdout, stderr, success].
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
# `env` adds environment variables for this command only.
|
|
20
|
+
def capture(*args, env: {})
|
|
21
|
+
stdout, stderr, status = Open3.capture3(env, *args)
|
|
21
22
|
[stdout, stderr, status.success?]
|
|
22
23
|
rescue SystemCallError => e
|
|
23
24
|
raise CommandError, "#{args.join(' ')} failed: #{e.message}"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitflash
|
|
4
|
+
module Git
|
|
5
|
+
# Runs every git command inside another directory (`git -C DIR ...`). It has the same
|
|
6
|
+
# interface as BashCommand, so StateCapture, Snapshots and Restore can work on a linked
|
|
7
|
+
# worktree without knowing about it.
|
|
8
|
+
class InDirectory
|
|
9
|
+
def initialize(path, bash: BashCommand)
|
|
10
|
+
@path = path
|
|
11
|
+
@bash = bash
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def exec(*args, env: {})
|
|
15
|
+
@bash.exec(*scoped(args), env: env)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def capture(*args, env: {})
|
|
19
|
+
@bash.capture(*scoped(args), env: env)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def scoped(args)
|
|
25
|
+
args.first == 'git' ? ['git', '-C', @path, *args.drop(1)] : args
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitflash
|
|
4
|
+
module Hook
|
|
5
|
+
# Marks the branches an agent just created as agent work (see Ownership), so
|
|
6
|
+
# `gitflash clean --agent` can find them. Runs in the PostToolUse hook, after the command:
|
|
7
|
+
# the branch exists then, and Claude Code only runs it when the command succeeded.
|
|
8
|
+
#
|
|
9
|
+
# A branch is marked only when it is unmarked and its reflog says it was created a moment
|
|
10
|
+
# ago ("branch: Created from ..."), so a branch that was already there is never claimed.
|
|
11
|
+
class BranchMarker
|
|
12
|
+
CREATED = 'branch: Created from'
|
|
13
|
+
RECENT_SECONDS = 120
|
|
14
|
+
|
|
15
|
+
def initialize(clock: -> { Time.now })
|
|
16
|
+
@clock = clock
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Branch names marked as agent work, in the directories the command ran in
|
|
20
|
+
def call(command, cwd)
|
|
21
|
+
created = CommandParser.new(cwd).creations(command)
|
|
22
|
+
created.select { |creation| mark(creation[:dir], creation[:branch]) }
|
|
23
|
+
.map { |creation| creation[:branch] }.uniq
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def mark(dir, branch)
|
|
29
|
+
return false unless Dir.exist?(dir)
|
|
30
|
+
|
|
31
|
+
Dir.chdir(dir) do
|
|
32
|
+
ownership = Ownership.new
|
|
33
|
+
next false unless created_recently?(branch) && !ownership.all.key?(branch)
|
|
34
|
+
|
|
35
|
+
ownership.mark(branch, 'agent')
|
|
36
|
+
true
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The oldest reflog entry of the branch is its creation, and it is recent
|
|
41
|
+
def created_recently?(branch)
|
|
42
|
+
stdout, _stderr, success = Git::BashCommand.capture(
|
|
43
|
+
'git', 'log', '-g', '--format=%ct%x1f%gs', "#{Repo::HEADS}#{branch}", '--'
|
|
44
|
+
)
|
|
45
|
+
return false unless success
|
|
46
|
+
|
|
47
|
+
created, subject = stdout.lines(chomp: true).last.to_s.split("\x1f", 2)
|
|
48
|
+
subject.to_s.start_with?(CREATED) && @clock.call.to_i - created.to_i <= RECENT_SECONDS
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Gitflash
|
|
6
|
+
module Hook
|
|
7
|
+
# Claude Code hook for the Bash tool. PostToolUse input marks the branches the command
|
|
8
|
+
# created as agent work (BranchMarker); everything below describes PreToolUse.
|
|
9
|
+
#
|
|
10
|
+
# Reads the hook input (JSON on stdin) and, when the command can lose work git cannot
|
|
11
|
+
# restore, acts according to the mode:
|
|
12
|
+
# snapshot save a snapshot and let the command run (default)
|
|
13
|
+
# ask save a snapshot and ask the user to approve the command
|
|
14
|
+
# deny block the command and point to the gitflash equivalent
|
|
15
|
+
# Returns the hook output as a Hash, or nil to leave the decision to Claude Code.
|
|
16
|
+
class Claude
|
|
17
|
+
MODES = %w[snapshot ask deny].freeze
|
|
18
|
+
EVENT = 'PreToolUse'
|
|
19
|
+
POST_EVENT = 'PostToolUse'
|
|
20
|
+
|
|
21
|
+
def initialize(mode: 'snapshot', snapshots: -> { Gitflash::Snapshots.new },
|
|
22
|
+
marker: BranchMarker.new)
|
|
23
|
+
@mode = mode
|
|
24
|
+
@snapshots = snapshots
|
|
25
|
+
@marker = marker
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call(input)
|
|
29
|
+
data = JSON.parse(input)
|
|
30
|
+
return after_command(data) if data['hook_event_name'] == POST_EVENT
|
|
31
|
+
|
|
32
|
+
targets = targets(data)
|
|
33
|
+
return nil if targets.empty?
|
|
34
|
+
return deny(targets) if @mode == 'deny'
|
|
35
|
+
|
|
36
|
+
saved = merge_by_dir(targets).filter_map { |target| save(target) }
|
|
37
|
+
saved.empty? ? nil : output(saved)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# PostToolUse: mark the branches the command created, and tell Claude
|
|
43
|
+
def after_command(data)
|
|
44
|
+
return nil unless data['tool_name'] == 'Bash'
|
|
45
|
+
|
|
46
|
+
marked = @marker.call(data.dig('tool_input', 'command').to_s, data['cwd'] || Dir.pwd)
|
|
47
|
+
return nil if marked.empty?
|
|
48
|
+
|
|
49
|
+
context = "gitflash marked #{marked.join(', ')} as agent work, so " \
|
|
50
|
+
'`gitflash clean --agent` can find it. Change it with ' \
|
|
51
|
+
'`gitflash mark BRANCH --owner human`.'
|
|
52
|
+
{ hookSpecificOutput: { hookEventName: POST_EVENT, additionalContext: context } }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def targets(data)
|
|
56
|
+
return [] unless data['tool_name'] == 'Bash'
|
|
57
|
+
|
|
58
|
+
command = data.dig('tool_input', 'command').to_s
|
|
59
|
+
CommandParser.new(data['cwd'] || Dir.pwd).targets(command)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# One snapshot per directory, covering every command run there
|
|
63
|
+
def merge_by_dir(targets)
|
|
64
|
+
targets.group_by(&:dir).map do |dir, group|
|
|
65
|
+
CommandParser::Target.new(
|
|
66
|
+
dir: dir, command: group.map(&:command).join(' && '),
|
|
67
|
+
scope: group.flat_map(&:scope).uniq, branches: group.flat_map(&:branches).uniq
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# [target, snapshot], or nil when the directory is not a git work tree
|
|
73
|
+
def save(target)
|
|
74
|
+
return nil unless Dir.exist?(target.dir)
|
|
75
|
+
|
|
76
|
+
Dir.chdir(target.dir) do
|
|
77
|
+
next nil unless git('rev-parse', '--is-inside-work-tree') == 'true'
|
|
78
|
+
|
|
79
|
+
snapshot = @snapshots.call.create(reason: "agent hook: before `#{target.command}`",
|
|
80
|
+
scope: target.scope, branches: branches(target))
|
|
81
|
+
[target, snapshot]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Full ref shortened by removing refs/heads/ only; `--short` changes when a tag has the name
|
|
86
|
+
def current_branch
|
|
87
|
+
git('symbolic-ref', '--quiet', 'HEAD')&.delete_prefix(Repo::HEADS)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def branches(target)
|
|
91
|
+
target.branches.filter_map do |name|
|
|
92
|
+
name == :current ? current_branch : name
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def output(saved)
|
|
97
|
+
notes = saved.map { |target, snap| "snapshot #{snap.id} before `#{target.command}`" }
|
|
98
|
+
context = "gitflash saved #{notes.join('; ')}. If this discards work that was still " \
|
|
99
|
+
"needed, restore it with `gitflash undo #{saved.last[1].id}` " \
|
|
100
|
+
'(list snapshots with `gitflash snapshots`).'
|
|
101
|
+
specific = { hookEventName: EVENT, additionalContext: context }
|
|
102
|
+
if @mode == 'ask'
|
|
103
|
+
specific[:permissionDecision] = 'ask'
|
|
104
|
+
specific[:permissionDecisionReason] =
|
|
105
|
+
"This command can discard work git cannot restore. #{context}"
|
|
106
|
+
end
|
|
107
|
+
{ hookSpecificOutput: specific }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def deny(targets)
|
|
111
|
+
commands = targets.map { |target| "`#{target.command}`" }.join(', ')
|
|
112
|
+
reason = "gitflash blocked #{commands}: it can discard work git cannot restore. " \
|
|
113
|
+
'Use the gitflash equivalent (gitflash reset, gitflash delete, ...), which ' \
|
|
114
|
+
'saves a snapshot first, or run `gitflash snapshot` before retrying.'
|
|
115
|
+
{ hookSpecificOutput: { hookEventName: EVENT, permissionDecision: 'deny',
|
|
116
|
+
permissionDecisionReason: reason } }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Stripped stdout of a git command, or nil when it fails
|
|
120
|
+
def git(*)
|
|
121
|
+
stdout, _stderr, success = Git::BashCommand.capture('git', *)
|
|
122
|
+
success ? stdout.strip : nil
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'shellwords'
|
|
4
|
+
|
|
5
|
+
module Gitflash
|
|
6
|
+
module Hook
|
|
7
|
+
# Finds git commands in a shell command line that can lose work git itself cannot restore:
|
|
8
|
+
# uncommitted or untracked files, deleted branches (their reflog is deleted with them) and
|
|
9
|
+
# dropped stashes. Commands that only add history (commit, merge, pull) are ignored because
|
|
10
|
+
# the reflog already covers them. See Rules for the list.
|
|
11
|
+
#
|
|
12
|
+
# The parser is deliberately simple: it splits on ; & | and newlines outside quotes, follows
|
|
13
|
+
# `cd DIR` and `git -C DIR`, and may report a command more often than needed. An extra
|
|
14
|
+
# snapshot is harmless; a missed one is not.
|
|
15
|
+
class CommandParser
|
|
16
|
+
# dir: where to take the snapshot; branches: names, or :current for the checked-out one
|
|
17
|
+
Target = Data.define(:dir, :command, :scope, :branches)
|
|
18
|
+
|
|
19
|
+
PREFIXES = %w[sudo command exec time nohup env].freeze
|
|
20
|
+
OPTIONS_WITH_VALUE = %w[-c --git-dir --work-tree --namespace --exec-path --config-env].freeze
|
|
21
|
+
|
|
22
|
+
def initialize(cwd)
|
|
23
|
+
@cwd = cwd
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Git commands that can lose work, with what to save before them
|
|
27
|
+
def targets(command_line)
|
|
28
|
+
git_commands(command_line).filter_map do |git|
|
|
29
|
+
rule = Rules.for(git.subcommand, git.args, git.dir)
|
|
30
|
+
next unless rule
|
|
31
|
+
|
|
32
|
+
Target.new(dir: rule.fetch(:dir, git.dir), command: git.command, scope: rule[:scope],
|
|
33
|
+
branches: rule[:branches] || [])
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Branches that git commands in the line create, as { dir:, branch: }
|
|
38
|
+
def creations(command_line)
|
|
39
|
+
git_commands(command_line).filter_map do |git|
|
|
40
|
+
branch = Creations.for(git.subcommand, git.args)
|
|
41
|
+
{ dir: git.dir, branch: branch } if branch
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# A git command found in the line: the directory it runs in (after `cd` and `-C`), the
|
|
48
|
+
# subcommand and its arguments
|
|
49
|
+
GitCommand = Data.define(:dir, :command, :subcommand, :args)
|
|
50
|
+
|
|
51
|
+
def git_commands(command_line)
|
|
52
|
+
dir = @cwd
|
|
53
|
+
segments(command_line).filter_map do |segment|
|
|
54
|
+
words = words(segment)
|
|
55
|
+
next if words.empty?
|
|
56
|
+
|
|
57
|
+
if words.first == 'cd'
|
|
58
|
+
dir = File.expand_path(words[1] || Dir.home, dir)
|
|
59
|
+
next
|
|
60
|
+
end
|
|
61
|
+
git_command(words, dir, segment.strip) if File.basename(words.first) == 'git'
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Splits on unquoted ; & | and newlines
|
|
66
|
+
def segments(line)
|
|
67
|
+
parts = [+'']
|
|
68
|
+
quote = nil
|
|
69
|
+
line.each_char do |char|
|
|
70
|
+
if quote
|
|
71
|
+
quote = nil if char == quote
|
|
72
|
+
elsif ["'", '"'].include?(char)
|
|
73
|
+
quote = char
|
|
74
|
+
elsif ";&|\n".include?(char)
|
|
75
|
+
parts << +''
|
|
76
|
+
next
|
|
77
|
+
end
|
|
78
|
+
parts.last << char
|
|
79
|
+
end
|
|
80
|
+
parts.reject { |part| part.strip.empty? }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Shell words without subshell brackets, environment assignments and wrappers like sudo
|
|
84
|
+
def words(segment)
|
|
85
|
+
words = split(segment).map { |word| unwrap(word) }.reject(&:empty?)
|
|
86
|
+
words.shift while words.first && prefix?(words.first)
|
|
87
|
+
words
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def prefix?(word)
|
|
91
|
+
PREFIXES.include?(word) || word.match?(/\A\w+=/)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def split(segment)
|
|
95
|
+
Shellwords.split(segment)
|
|
96
|
+
rescue ArgumentError
|
|
97
|
+
segment.split
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def unwrap(word)
|
|
101
|
+
word.delete_prefix('(').delete_prefix('{').delete_suffix(')').delete_suffix('}')
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def git_command(words, dir, command)
|
|
105
|
+
index, dir = skip_git_options(words, dir)
|
|
106
|
+
GitCommand.new(dir: dir, command: command, subcommand: words[index],
|
|
107
|
+
args: words[(index + 1)..] || [])
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Index of the subcommand, and the directory after any -C options
|
|
111
|
+
def skip_git_options(words, dir)
|
|
112
|
+
index = 1
|
|
113
|
+
while (option = words[index])&.start_with?('-')
|
|
114
|
+
dir = File.expand_path(words[index + 1].to_s, dir) if option == '-C'
|
|
115
|
+
index += option == '-C' || OPTIONS_WITH_VALUE.include?(option) ? 2 : 1
|
|
116
|
+
end
|
|
117
|
+
[index, dir]
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|