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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -0
- data/README.md +26 -2
- data/command_descriptions.yml +80 -0
- data/lib/gitflash/branch.rb +7 -2
- data/lib/gitflash/cherry_pick/log_parser.rb +63 -0
- data/lib/gitflash/cherry_pick.rb +120 -0
- data/lib/gitflash/cleanup.rb +70 -0
- data/lib/gitflash/cli.rb +38 -1
- data/lib/gitflash/commands/base.rb +12 -3
- data/lib/gitflash/commands/branches.rb +1 -0
- data/lib/gitflash/commands/clean.rb +51 -0
- data/lib/gitflash/commands/clean_criteria.rb +29 -0
- data/lib/gitflash/commands/delete.rb +17 -5
- data/lib/gitflash/commands/hook_install.rb +31 -10
- data/lib/gitflash/commands/hook_status.rb +24 -6
- data/lib/gitflash/commands/mark.rb +85 -0
- data/lib/gitflash/commands/pick.rb +143 -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 +70 -0
- data/lib/gitflash/commands/undo.rb +41 -6
- data/lib/gitflash/commands/worktree_add.rb +100 -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 +58 -0
- data/lib/gitflash/commands/worktree_move.rb +52 -0
- data/lib/gitflash/commands/worktree_prune.rb +41 -0
- data/lib/gitflash/commands/worktree_remove.rb +126 -0
- data/lib/gitflash/commands/worktree_unlock.rb +12 -0
- data/lib/gitflash/config.rb +98 -0
- data/lib/gitflash/config_keys.rb +31 -0
- data/lib/gitflash/git/bash_command.rb +5 -2
- data/lib/gitflash/git/in_directory.rb +29 -0
- data/lib/gitflash/hook/branch_marker.rb +155 -0
- data/lib/gitflash/hook/claude.rb +54 -18
- data/lib/gitflash/hook/command_parser.rb +50 -12
- data/lib/gitflash/hook/creations.rb +85 -0
- data/lib/gitflash/hook/messages.rb +38 -0
- data/lib/gitflash/hook/rules.rb +6 -4
- data/lib/gitflash/hook/settings.rb +18 -5
- data/lib/gitflash/hook_cli.rb +20 -5
- data/lib/gitflash/ownership.rb +59 -0
- data/lib/gitflash/protection.rb +19 -0
- data/lib/gitflash/repo.rb +5 -5
- data/lib/gitflash/restore.rb +8 -7
- data/lib/gitflash/state_capture.rb +10 -5
- data/lib/gitflash/terminal_text.rb +9 -0
- data/lib/gitflash/ui.rb +1 -1
- 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 +1050 -2
- metadata +32 -1
|
@@ -0,0 +1,98 @@
|
|
|
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. The repository file wins
|
|
12
|
+
# key by key, except `protected`: both files add patterns, so a repository can protect more
|
|
13
|
+
# branches but never remove what the user protects.
|
|
14
|
+
def self.load(root:, home: Dir.home)
|
|
15
|
+
layers = [File.join(home, ConfigKeys::USER_FILE), File.join(root, ConfigKeys::REPO_FILE)]
|
|
16
|
+
.map { |path| read(path) }
|
|
17
|
+
values = layers.reduce(ConfigKeys::DEFAULTS) { |merged, layer| merged.merge(layer) }
|
|
18
|
+
new(protected_patterns: layers.flat_map { |layer| layer.fetch('protected', []) }.uniq,
|
|
19
|
+
stale_days: values['stale_days'], worktree_dir: values['worktree_dir'])
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.defaults
|
|
23
|
+
new(protected_patterns: [], stale_days: 30, worktree_dir: ConfigKeys::DEFAULT_WORKTREE_DIR)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.read(path)
|
|
27
|
+
return {} unless File.file?(path)
|
|
28
|
+
|
|
29
|
+
reject_duplicate_keys!(path)
|
|
30
|
+
data = YAML.safe_load_file(path) || {}
|
|
31
|
+
validate!(data, path)
|
|
32
|
+
data
|
|
33
|
+
rescue Psych::Exception, SystemCallError, IOError => e
|
|
34
|
+
raise invalid(path, "cannot be read (#{e.message.lines.first.strip})")
|
|
35
|
+
end
|
|
36
|
+
private_class_method :read
|
|
37
|
+
|
|
38
|
+
# A YAML loader keeps only the last of two equal keys, which could drop a `protected` list
|
|
39
|
+
def self.reject_duplicate_keys!(path)
|
|
40
|
+
root = Psych.parse_file(path)
|
|
41
|
+
return unless root
|
|
42
|
+
|
|
43
|
+
duplicate = duplicate_key(root)
|
|
44
|
+
raise invalid(path, "has the key '#{duplicate}' more than once") if duplicate
|
|
45
|
+
end
|
|
46
|
+
private_class_method :reject_duplicate_keys!
|
|
47
|
+
|
|
48
|
+
def self.duplicate_key(node)
|
|
49
|
+
own = node.is_a?(Psych::Nodes::Mapping) ? own_duplicate(node) : nil
|
|
50
|
+
own || (node.children || []).filter_map { |child| duplicate_key(child) }.first
|
|
51
|
+
end
|
|
52
|
+
private_class_method :duplicate_key
|
|
53
|
+
|
|
54
|
+
def self.own_duplicate(mapping)
|
|
55
|
+
keys = mapping.children.each_slice(2).filter_map do |key, _value|
|
|
56
|
+
key.value if key.respond_to?(:value)
|
|
57
|
+
end
|
|
58
|
+
keys.tally.find { |_key, count| count > 1 }&.first
|
|
59
|
+
end
|
|
60
|
+
private_class_method :own_duplicate
|
|
61
|
+
|
|
62
|
+
def self.validate!(data, path)
|
|
63
|
+
raise invalid(path, 'must be a mapping') unless data.is_a?(Hash)
|
|
64
|
+
|
|
65
|
+
unknown = data.keys - ConfigKeys::DEFAULTS.keys
|
|
66
|
+
raise invalid(path, "has unknown keys: #{unknown.join(', ')}") if unknown.any?
|
|
67
|
+
|
|
68
|
+
check_types!(data, path)
|
|
69
|
+
end
|
|
70
|
+
private_class_method :validate!
|
|
71
|
+
|
|
72
|
+
def self.check_types!(data, path)
|
|
73
|
+
data.each do |key, value|
|
|
74
|
+
check, expected = ConfigKeys::CHECKS.fetch(key)
|
|
75
|
+
raise invalid(path, "'#{key}' must be #{expected}") unless check.call(value)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
private_class_method :check_types!
|
|
79
|
+
|
|
80
|
+
def self.invalid(path, problem)
|
|
81
|
+
UsageError.new("#{path} #{problem}", code: 'invalid_config')
|
|
82
|
+
end
|
|
83
|
+
private_class_method :invalid
|
|
84
|
+
|
|
85
|
+
# True when the branch name matches a configured pattern (shell-style `*` and `?`)
|
|
86
|
+
def protected?(name)
|
|
87
|
+
protected_patterns.any? { |pattern| File.fnmatch?(pattern, name) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Directory for a new worktree. `%<repo>s` and `%<branch>s` are replaced as plain text; a
|
|
91
|
+
# relative path is resolved against the main checkout. Slashes in a branch name stay as
|
|
92
|
+
# folders.
|
|
93
|
+
def worktree_path(root:, branch:)
|
|
94
|
+
path = worktree_dir.gsub('%<repo>s') { File.basename(root) }.gsub('%<branch>s') { branch }
|
|
95
|
+
File.expand_path(path, root)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
PLACEHOLDERS = ['%<repo>s', '%<branch>s'].freeze
|
|
13
|
+
MAX_TEMPLATE_LENGTH = 512
|
|
14
|
+
CHECKS = {
|
|
15
|
+
'protected' => [->(v) { v.is_a?(Array) && v.all?(String) },
|
|
16
|
+
'a list of branch name patterns'],
|
|
17
|
+
'stale_days' => [->(v) { v.is_a?(Integer) && v.positive? }, 'a positive whole number'],
|
|
18
|
+
'worktree_dir' => [->(v) { template?(v) },
|
|
19
|
+
'a path of up to 512 characters that uses only %<repo>s and %<branch>s']
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
# A path template: the two placeholders are the only `%` directives, so a width such as
|
|
23
|
+
# `%<repo>999999999s` can never make Ruby build a huge string
|
|
24
|
+
def self.template?(value)
|
|
25
|
+
return false unless value.is_a?(String)
|
|
26
|
+
return false if value.empty? || value.length > MAX_TEMPLATE_LENGTH
|
|
27
|
+
|
|
28
|
+
!PLACEHOLDERS.reduce(value) { |rest, placeholder| rest.gsub(placeholder, '') }.include?('%')
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -16,9 +16,12 @@ module Gitflash
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
# Runs a command without a shell and returns [stdout, stderr, success].
|
|
19
|
-
# `env` adds environment variables for this command only.
|
|
19
|
+
# `env` adds environment variables for this command only. The executable is passed as a
|
|
20
|
+
# pair, which keeps Ruby from handing a lone string to a shell.
|
|
20
21
|
def capture(*args, env: {})
|
|
21
|
-
|
|
22
|
+
raise CommandError, 'no command given' if args.empty?
|
|
23
|
+
|
|
24
|
+
stdout, stderr, status = Open3.capture3(env, [args.first, args.first], *args.drop(1))
|
|
22
25
|
[stdout, stderr, status.success?]
|
|
23
26
|
rescue SystemCallError => e
|
|
24
27
|
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,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'tmpdir'
|
|
7
|
+
|
|
8
|
+
module Gitflash
|
|
9
|
+
module Hook
|
|
10
|
+
# Marks the branches an agent just created as agent work (see Ownership), so
|
|
11
|
+
# `gitflash clean --agent` can find them. Runs in the PostToolUse hook, after the command:
|
|
12
|
+
# the branch exists then, and Claude Code only runs it when the command succeeded.
|
|
13
|
+
#
|
|
14
|
+
# Text in a command proves nothing (`false && git branch x` creates nothing), so a branch is
|
|
15
|
+
# only marked when it is new:
|
|
16
|
+
# - the PreToolUse hook records which of the branches the command names exist before it runs
|
|
17
|
+
# (`record`), and a branch that existed then is never marked;
|
|
18
|
+
# - its reflog must say it was created a moment ago ("branch: Created from ..."), which also
|
|
19
|
+
# covers a Claude Code that ran no PreToolUse hook;
|
|
20
|
+
# - it must be unmarked.
|
|
21
|
+
class BranchMarker
|
|
22
|
+
CREATED = 'branch: Created from'
|
|
23
|
+
RECENT_SECONDS = 120
|
|
24
|
+
STALE_RECORD_SECONDS = 24 * 60 * 60
|
|
25
|
+
|
|
26
|
+
def initialize(clock: -> { Time.now }, state_dir: nil)
|
|
27
|
+
@clock = clock
|
|
28
|
+
@state_dir = state_dir || File.join(Dir.tmpdir, "gitflash-hook-#{Process.uid}")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# PreToolUse: remember the branches the command may create that exist already
|
|
32
|
+
def record(command, cwd, tool_use_id)
|
|
33
|
+
creations = CommandParser.new(cwd).creations(command)
|
|
34
|
+
return if creations.empty? || (path = record_path(tool_use_id)).nil?
|
|
35
|
+
return unless private_state_dir?
|
|
36
|
+
|
|
37
|
+
prune_records
|
|
38
|
+
File.write(path, JSON.generate(existing_stamps(creations)))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# PostToolUse: branch names marked as agent work, in the directories the command ran in
|
|
42
|
+
def call(command, cwd, tool_use_id = nil)
|
|
43
|
+
existed = take_record(tool_use_id)
|
|
44
|
+
created = CommandParser.new(cwd).creations(command)
|
|
45
|
+
created.reject { |creation| already_there?(existed, creation) }
|
|
46
|
+
.select { |creation| mark(creation[:dir], creation[:branch]) }
|
|
47
|
+
.map { |creation| creation[:branch] }.uniq
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# { key => stamp } of the named branches that exist now
|
|
53
|
+
def existing_stamps(creations)
|
|
54
|
+
existing = creations.select { |creation| branch_exists?(creation[:dir], creation[:branch]) }
|
|
55
|
+
existing.to_h { |creation| [key(creation), stamp(creation)] }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def key(creation)
|
|
59
|
+
"#{creation[:dir]}\0#{creation[:branch]}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A branch that was there before the command and is the same branch now. One that the
|
|
63
|
+
# command deleted and created again has a new reflog, so a new stamp, and counts as new.
|
|
64
|
+
# An empty stamp (no reflog) is no evidence of a new branch either: all that is known is
|
|
65
|
+
# that the branch existed, and claiming a branch a person may have made is the worse
|
|
66
|
+
# mistake, so it is left alone. Without a reflog nothing could be marked in any case.
|
|
67
|
+
def already_there?(existed, creation)
|
|
68
|
+
existed.key?(key(creation)) && existed[key(creation)] == stamp(creation)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Time, commit and message of the oldest reflog entry, which is the creation of the branch;
|
|
72
|
+
# empty when the reflog is off
|
|
73
|
+
def stamp(creation)
|
|
74
|
+
return '' unless Dir.exist?(creation[:dir])
|
|
75
|
+
|
|
76
|
+
stdout, _stderr, success = Git::BashCommand.capture(
|
|
77
|
+
'git', '-C', creation[:dir], 'log', '-g', '--format=%ct %H %gs',
|
|
78
|
+
"#{Repo::HEADS}#{creation[:branch]}", '--'
|
|
79
|
+
)
|
|
80
|
+
success ? stdout.lines(chomp: true).last.to_s : ''
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# One file per tool call, named from a hash so the id can never form a path
|
|
84
|
+
def record_path(tool_use_id)
|
|
85
|
+
return nil if tool_use_id.to_s.empty?
|
|
86
|
+
|
|
87
|
+
File.join(@state_dir, "#{Digest::SHA256.hexdigest(tool_use_id.to_s)}.json")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def take_record(tool_use_id)
|
|
91
|
+
path = record_path(tool_use_id)
|
|
92
|
+
return {} unless path && private_state_dir? && File.file?(path)
|
|
93
|
+
|
|
94
|
+
record = JSON.parse(File.read(path))
|
|
95
|
+
record.is_a?(Hash) ? record : {}
|
|
96
|
+
rescue JSON::ParserError
|
|
97
|
+
{}
|
|
98
|
+
ensure
|
|
99
|
+
FileUtils.rm_f(path) if path
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The directory holds the records of this user only: created with mode 0700, and refused
|
|
103
|
+
# when it is a link, belongs to someone else or is open to the group or others (a shared
|
|
104
|
+
# /tmp lets another user create the name first)
|
|
105
|
+
def private_state_dir?
|
|
106
|
+
FileUtils.mkdir_p(@state_dir, mode: 0o700)
|
|
107
|
+
stat = File.lstat(@state_dir)
|
|
108
|
+
stat.directory? && stat.owned? && stat.mode.nobits?(0o077)
|
|
109
|
+
rescue SystemCallError
|
|
110
|
+
false
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Another hook may delete a record at the same moment: a vanished file is not an error
|
|
114
|
+
def prune_records
|
|
115
|
+
Dir.glob(File.join(@state_dir, '*.json')).each do |file|
|
|
116
|
+
FileUtils.rm_f(file) if @clock.call - File.mtime(file) > STALE_RECORD_SECONDS
|
|
117
|
+
rescue SystemCallError
|
|
118
|
+
next
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def branch_exists?(dir, branch)
|
|
123
|
+
return false unless Dir.exist?(dir)
|
|
124
|
+
|
|
125
|
+
_stdout, _stderr, success = Git::BashCommand.capture(
|
|
126
|
+
'git', '-C', dir, 'show-ref', '--verify', '--quiet', "#{Repo::HEADS}#{branch}"
|
|
127
|
+
)
|
|
128
|
+
success
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def mark(dir, branch)
|
|
132
|
+
return false unless Dir.exist?(dir)
|
|
133
|
+
|
|
134
|
+
Dir.chdir(dir) do
|
|
135
|
+
ownership = Ownership.new
|
|
136
|
+
next false unless created_recently?(branch) && !ownership.all.key?(branch)
|
|
137
|
+
|
|
138
|
+
ownership.mark(branch, 'agent')
|
|
139
|
+
true
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The oldest reflog entry of the branch is its creation, and it is recent
|
|
144
|
+
def created_recently?(branch)
|
|
145
|
+
stdout, _stderr, success = Git::BashCommand.capture(
|
|
146
|
+
'git', 'log', '-g', '--format=%ct%x1f%gs', "#{Repo::HEADS}#{branch}", '--'
|
|
147
|
+
)
|
|
148
|
+
return false unless success
|
|
149
|
+
|
|
150
|
+
created, subject = stdout.lines(chomp: true).last.to_s.split("\x1f", 2)
|
|
151
|
+
subject.to_s.start_with?(CREATED) && @clock.call.to_i - created.to_i <= RECENT_SECONDS
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
data/lib/gitflash/hook/claude.rb
CHANGED
|
@@ -4,7 +4,8 @@ require 'json'
|
|
|
4
4
|
|
|
5
5
|
module Gitflash
|
|
6
6
|
module Hook
|
|
7
|
-
# Claude Code
|
|
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.
|
|
8
9
|
#
|
|
9
10
|
# Reads the hook input (JSON on stdin) and, when the command can lose work git cannot
|
|
10
11
|
# restore, acts according to the mode:
|
|
@@ -15,28 +16,70 @@ module Gitflash
|
|
|
15
16
|
class Claude
|
|
16
17
|
MODES = %w[snapshot ask deny].freeze
|
|
17
18
|
EVENT = 'PreToolUse'
|
|
19
|
+
POST_EVENT = 'PostToolUse'
|
|
18
20
|
|
|
19
|
-
def initialize(mode: 'snapshot', snapshots: -> { Gitflash::Snapshots.new }
|
|
21
|
+
def initialize(mode: 'snapshot', snapshots: -> { Gitflash::Snapshots.new },
|
|
22
|
+
marker: BranchMarker.new)
|
|
20
23
|
@mode = mode
|
|
21
24
|
@snapshots = snapshots
|
|
25
|
+
@marker = marker
|
|
22
26
|
end
|
|
23
27
|
|
|
24
28
|
def call(input)
|
|
25
|
-
|
|
29
|
+
data = JSON.parse(input)
|
|
30
|
+
return after_command(data) if data['hook_event_name'] == POST_EVENT
|
|
31
|
+
|
|
32
|
+
before_command(data)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# PreToolUse: remember which branches exist, then snapshot, ask or deny for risky commands
|
|
38
|
+
def before_command(data)
|
|
39
|
+
record_branches(data)
|
|
40
|
+
targets = targets(data)
|
|
26
41
|
return nil if targets.empty?
|
|
27
42
|
return deny(targets) if @mode == 'deny'
|
|
28
43
|
|
|
29
44
|
saved = merge_by_dir(targets).filter_map { |target| save(target) }
|
|
30
|
-
|
|
45
|
+
# Asking is the user's protection: it must not depend on a snapshot having been saved
|
|
46
|
+
saved.empty? && @mode != 'ask' ? nil : output(saved, targets)
|
|
31
47
|
end
|
|
32
48
|
|
|
33
|
-
|
|
49
|
+
# Recording is a side feature: when it fails, snapshots and the ask or deny decision still run
|
|
50
|
+
def record_branches(data)
|
|
51
|
+
return unless bash?(data)
|
|
52
|
+
|
|
53
|
+
@marker.record(command_of(data), data['cwd'] || Dir.pwd, data['tool_use_id'])
|
|
54
|
+
rescue StandardError => e
|
|
55
|
+
warn "gitflash hook: could not record branches (#{e.message})"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# PostToolUse: mark the branches the command created, and tell Claude
|
|
59
|
+
def after_command(data)
|
|
60
|
+
return nil unless data['tool_name'] == 'Bash'
|
|
61
|
+
|
|
62
|
+
marked = @marker.call(command_of(data), data['cwd'] || Dir.pwd, data['tool_use_id'])
|
|
63
|
+
return nil if marked.empty?
|
|
64
|
+
|
|
65
|
+
context = "gitflash marked #{marked.join(', ')} as agent work, so " \
|
|
66
|
+
'`gitflash clean --agent` can find it. Change it with ' \
|
|
67
|
+
'`gitflash mark BRANCH --owner human`.'
|
|
68
|
+
{ hookSpecificOutput: { hookEventName: POST_EVENT, additionalContext: context } }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def bash?(data)
|
|
72
|
+
data['tool_name'] == 'Bash'
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def command_of(data)
|
|
76
|
+
data.dig('tool_input', 'command').to_s
|
|
77
|
+
end
|
|
34
78
|
|
|
35
79
|
def targets(data)
|
|
36
|
-
return [] unless data
|
|
80
|
+
return [] unless bash?(data)
|
|
37
81
|
|
|
38
|
-
|
|
39
|
-
CommandParser.new(data['cwd'] || Dir.pwd).targets(command)
|
|
82
|
+
CommandParser.new(data['cwd'] || Dir.pwd).targets(command_of(data))
|
|
40
83
|
end
|
|
41
84
|
|
|
42
85
|
# One snapshot per directory, covering every command run there
|
|
@@ -73,11 +116,8 @@ module Gitflash
|
|
|
73
116
|
end
|
|
74
117
|
end
|
|
75
118
|
|
|
76
|
-
def output(saved)
|
|
77
|
-
|
|
78
|
-
context = "gitflash saved #{notes.join('; ')}. If this discards work that was still " \
|
|
79
|
-
"needed, restore it with `gitflash undo #{saved.last[1].id}` " \
|
|
80
|
-
'(list snapshots with `gitflash snapshots`).'
|
|
119
|
+
def output(saved, targets)
|
|
120
|
+
context = saved.empty? ? Messages.unsaved(targets) : Messages.saved(saved)
|
|
81
121
|
specific = { hookEventName: EVENT, additionalContext: context }
|
|
82
122
|
if @mode == 'ask'
|
|
83
123
|
specific[:permissionDecision] = 'ask'
|
|
@@ -88,12 +128,8 @@ module Gitflash
|
|
|
88
128
|
end
|
|
89
129
|
|
|
90
130
|
def deny(targets)
|
|
91
|
-
commands = targets.map { |target| "`#{target.command}`" }.join(', ')
|
|
92
|
-
reason = "gitflash blocked #{commands}: it can discard work git cannot restore. " \
|
|
93
|
-
'Use the gitflash equivalent (gitflash reset, gitflash delete, ...), which ' \
|
|
94
|
-
'saves a snapshot first, or run `gitflash snapshot` before retrying.'
|
|
95
131
|
{ hookSpecificOutput: { hookEventName: EVENT, permissionDecision: 'deny',
|
|
96
|
-
permissionDecisionReason:
|
|
132
|
+
permissionDecisionReason: Messages.denial(targets) } }
|
|
97
133
|
end
|
|
98
134
|
|
|
99
135
|
# Stripped stdout of a git command, or nil when it fails
|
|
@@ -17,27 +17,68 @@ module Gitflash
|
|
|
17
17
|
Target = Data.define(:dir, :command, :scope, :branches)
|
|
18
18
|
|
|
19
19
|
PREFIXES = %w[sudo command exec time nohup env].freeze
|
|
20
|
+
SHELLS = %w[bash sh zsh dash ksh].freeze
|
|
21
|
+
MAX_NESTING = 3
|
|
20
22
|
OPTIONS_WITH_VALUE = %w[-c --git-dir --work-tree --namespace --exec-path --config-env].freeze
|
|
21
23
|
|
|
22
24
|
def initialize(cwd)
|
|
23
25
|
@cwd = cwd
|
|
24
26
|
end
|
|
25
27
|
|
|
28
|
+
# Git commands that can lose work, with what to save before them
|
|
26
29
|
def targets(command_line)
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
git_commands(command_line).filter_map do |git|
|
|
31
|
+
rule = Rules.for(git.subcommand, git.args, git.dir)
|
|
32
|
+
next unless rule
|
|
33
|
+
|
|
34
|
+
Target.new(dir: rule.fetch(:dir, git.dir), command: git.command, scope: rule[:scope],
|
|
35
|
+
branches: rule[:branches] || [])
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Branches that git commands in the line create, as { dir:, branch: }
|
|
40
|
+
def creations(command_line)
|
|
41
|
+
git_commands(command_line).filter_map do |git|
|
|
42
|
+
branch = Creations.for(git.subcommand, git.args)
|
|
43
|
+
{ dir: git.dir, branch: branch } if branch
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# A git command found in the line: the directory it runs in (after `cd` and `-C`), the
|
|
50
|
+
# subcommand and its arguments
|
|
51
|
+
GitCommand = Data.define(:dir, :command, :subcommand, :args)
|
|
52
|
+
|
|
53
|
+
def git_commands(command_line, dir = @cwd, depth = 0)
|
|
54
|
+
segments(command_line).flat_map do |segment|
|
|
29
55
|
words = words(segment)
|
|
30
|
-
next if words.empty?
|
|
56
|
+
next [] if words.empty?
|
|
31
57
|
|
|
32
58
|
if words.first == 'cd'
|
|
33
59
|
dir = File.expand_path(words[1] || Dir.home, dir)
|
|
34
|
-
next
|
|
60
|
+
next []
|
|
35
61
|
end
|
|
36
|
-
|
|
62
|
+
commands_in(words, dir, segment.strip, depth)
|
|
37
63
|
end
|
|
38
64
|
end
|
|
39
65
|
|
|
40
|
-
|
|
66
|
+
# The git command of one segment, or the ones inside `bash -c "..."` and `eval "..."`
|
|
67
|
+
def commands_in(words, dir, segment, depth)
|
|
68
|
+
return [git_command(words, dir, segment)] if File.basename(words.first) == 'git'
|
|
69
|
+
|
|
70
|
+
script = nested_script(words)
|
|
71
|
+
script && depth < MAX_NESTING ? git_commands(script, dir, depth + 1) : []
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The script text passed to a shell (`bash -c`, `sh -lc`) or to `eval`
|
|
75
|
+
def nested_script(words)
|
|
76
|
+
return words.drop(1).join(' ') if words.first == 'eval'
|
|
77
|
+
return nil unless SHELLS.include?(File.basename(words.first))
|
|
78
|
+
|
|
79
|
+
index = words.index { |word| word.match?(/\A-[a-z]*c[a-z]*\z/) }
|
|
80
|
+
index && words[index + 1]
|
|
81
|
+
end
|
|
41
82
|
|
|
42
83
|
# Splits on unquoted ; & | and newlines
|
|
43
84
|
def segments(line)
|
|
@@ -78,13 +119,10 @@ module Gitflash
|
|
|
78
119
|
word.delete_prefix('(').delete_prefix('{').delete_suffix(')').delete_suffix('}')
|
|
79
120
|
end
|
|
80
121
|
|
|
81
|
-
def
|
|
122
|
+
def git_command(words, dir, command)
|
|
82
123
|
index, dir = skip_git_options(words, dir)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
Target.new(dir: rule.fetch(:dir, dir), command: command, scope: rule[:scope],
|
|
87
|
-
branches: rule[:branches] || [])
|
|
124
|
+
GitCommand.new(dir: dir, command: command, subcommand: words[index],
|
|
125
|
+
args: words[(index + 1)..] || [])
|
|
88
126
|
end
|
|
89
127
|
|
|
90
128
|
# Index of the subcommand, and the directory after any -C options
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitflash
|
|
4
|
+
module Hook
|
|
5
|
+
# Which git commands create a branch, and the name of it: `checkout -b`, `switch -c`,
|
|
6
|
+
# `branch NAME` and `worktree add -b` (or a path without a commit, which names the branch
|
|
7
|
+
# after the directory). Returns the name, or nil when the command creates no branch.
|
|
8
|
+
module Creations
|
|
9
|
+
NOT_CREATING = %w[
|
|
10
|
+
-d -D --delete -m -M --move -c -C --copy -u --set-upstream-to --unset-upstream
|
|
11
|
+
--edit-description --list -a --all -r --remotes -v -vv --verbose --show-current --contains
|
|
12
|
+
--no-contains --merged --no-merged --points-at
|
|
13
|
+
].freeze
|
|
14
|
+
WORKTREE_VALUE_OPTIONS = %w[-b -B --reason --orphan].freeze
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def for(subcommand, args)
|
|
19
|
+
case subcommand
|
|
20
|
+
when 'checkout' then flagged(args, %w[-b -B])
|
|
21
|
+
when 'switch' then flagged(args, %w[-c -C --create --force-create])
|
|
22
|
+
when 'branch' then plain_branch(args)
|
|
23
|
+
when 'worktree' then worktree(args)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The value of the first of `flags`: `-b NAME`, `-bNAME` or `--create=NAME`
|
|
28
|
+
def flagged(args, flags)
|
|
29
|
+
args.each_with_index do |arg, index|
|
|
30
|
+
return args[index + 1] if flags.include?(arg) && args[index + 1]
|
|
31
|
+
|
|
32
|
+
value = attached_value(arg, flags)
|
|
33
|
+
return value if value
|
|
34
|
+
end
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def attached_value(arg, flags)
|
|
39
|
+
flags.each do |flag|
|
|
40
|
+
if flag.start_with?('--')
|
|
41
|
+
return arg.delete_prefix("#{flag}=") if arg.start_with?("#{flag}=")
|
|
42
|
+
elsif arg.start_with?(flag) && arg.length > flag.length && !arg.start_with?('--')
|
|
43
|
+
return arg.delete_prefix(flag)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# `git branch NAME [START]` creates NAME; listing, deleting or renaming flags mean it does not
|
|
50
|
+
def plain_branch(args)
|
|
51
|
+
return nil if args.intersect?(NOT_CREATING)
|
|
52
|
+
|
|
53
|
+
args.find { |arg| !arg.start_with?('-') }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def worktree(args)
|
|
57
|
+
return nil unless args.first == 'add'
|
|
58
|
+
|
|
59
|
+
rest = args.drop(1)
|
|
60
|
+
named = flagged(rest, %w[-b -B])
|
|
61
|
+
return named if named
|
|
62
|
+
return nil if rest.include?('--detach') || rest.include?('--orphan')
|
|
63
|
+
|
|
64
|
+
path = positional(rest)
|
|
65
|
+
path.size == 1 ? File.basename(path.first) : nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Arguments that are not options or the values of options that take one
|
|
69
|
+
def positional(args)
|
|
70
|
+
result = []
|
|
71
|
+
skip = false
|
|
72
|
+
args.each do |arg|
|
|
73
|
+
if skip
|
|
74
|
+
skip = false
|
|
75
|
+
elsif WORKTREE_VALUE_OPTIONS.include?(arg)
|
|
76
|
+
skip = true
|
|
77
|
+
elsif !arg.start_with?('-')
|
|
78
|
+
result << arg
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
result
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitflash
|
|
4
|
+
module Hook
|
|
5
|
+
# What the PreToolUse hook tells Claude about a risky command
|
|
6
|
+
module Messages
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def saved(saved)
|
|
10
|
+
notes = saved.map { |target, snap| "snapshot #{snap.id} before `#{target.command}`" }
|
|
11
|
+
"gitflash saved #{notes.join('; ')}. If this discards work that was still needed, " \
|
|
12
|
+
"restore it with #{undo_commands(saved)} (list snapshots with `gitflash snapshots`)."
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# One command per snapshot: with several directories each snapshot belongs to its own
|
|
16
|
+
def undo_commands(saved)
|
|
17
|
+
return "`gitflash undo #{saved.first[1].id}`" if saved.size == 1
|
|
18
|
+
|
|
19
|
+
saved.map { |target, snap| "`gitflash undo #{snap.id}` (run in #{target.dir})" }.join(', ')
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def unsaved(targets)
|
|
23
|
+
"gitflash could not save a snapshot before #{commands(targets)} (the directory is not a " \
|
|
24
|
+
'git work tree or does not exist), so nothing can be restored with `gitflash undo`.'
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def denial(targets)
|
|
28
|
+
"gitflash blocked #{commands(targets)}: it can discard work git cannot restore. " \
|
|
29
|
+
'Use the gitflash equivalent (gitflash reset, gitflash delete, ...), which ' \
|
|
30
|
+
'saves a snapshot first, or run `gitflash snapshot` before retrying.'
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def commands(targets)
|
|
34
|
+
targets.map { |target| "`#{target.command}`" }.join(', ')
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|