gitflash 0.7.0.beta1 → 0.7.0.beta3
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 +59 -0
- data/README.md +17 -13
- data/command_descriptions.yml +25 -7
- data/lib/gitflash/atomic_file.rb +40 -0
- data/lib/gitflash/cherry_pick/log_parser.rb +63 -0
- data/lib/gitflash/cherry_pick.rb +10 -18
- data/lib/gitflash/cli.rb +28 -5
- data/lib/gitflash/command_runner.rb +12 -1
- data/lib/gitflash/commands/base.rb +3 -0
- data/lib/gitflash/commands/clean.rb +29 -1
- data/lib/gitflash/commands/gc.rb +19 -4
- data/lib/gitflash/commands/hook_install.rb +79 -12
- data/lib/gitflash/commands/mark.rb +9 -3
- data/lib/gitflash/commands/pick.rb +13 -1
- data/lib/gitflash/commands/pick_control.rb +1 -1
- data/lib/gitflash/commands/removal_report.rb +8 -3
- data/lib/gitflash/commands/snapshot_create.rb +6 -4
- data/lib/gitflash/commands/undo.rb +24 -11
- data/lib/gitflash/commands/worktree_add.rb +6 -4
- data/lib/gitflash/commands/worktree_command.rb +17 -3
- data/lib/gitflash/commands/worktree_list.rb +3 -3
- data/lib/gitflash/commands/worktree_lock.rb +7 -3
- data/lib/gitflash/commands/worktree_move.rb +9 -4
- data/lib/gitflash/commands/worktree_prune.rb +1 -1
- data/lib/gitflash/commands/worktree_remove.rb +23 -7
- data/lib/gitflash/config.rb +69 -16
- data/lib/gitflash/config_keys.rb +64 -4
- data/lib/gitflash/confirmation_required.rb +6 -2
- data/lib/gitflash/git/bash_command.rb +5 -2
- data/lib/gitflash/hook/branch_marker.rb +109 -6
- data/lib/gitflash/hook/claude.rb +45 -24
- data/lib/gitflash/hook/command_parser.rb +100 -59
- data/lib/gitflash/hook/command_words.rb +73 -0
- data/lib/gitflash/hook/failure.rb +29 -0
- data/lib/gitflash/hook/git_invocation.rb +119 -0
- data/lib/gitflash/hook/history_rules.rb +81 -0
- data/lib/gitflash/hook/losses.rb +51 -0
- data/lib/gitflash/hook/messages.rb +64 -0
- data/lib/gitflash/hook/paths.rb +47 -0
- data/lib/gitflash/hook/rules.rb +36 -12
- data/lib/gitflash/hook/settings.rb +19 -2
- data/lib/gitflash/hook/shell_quotes.rb +64 -0
- data/lib/gitflash/hook/shell_text.rb +101 -0
- data/lib/gitflash/hook/wrappers.rb +67 -0
- data/lib/gitflash/hook_cli.rb +34 -4
- data/lib/gitflash/protection.rb +12 -4
- data/lib/gitflash/repo.rb +3 -2
- data/lib/gitflash/snapshot.rb +9 -2
- data/lib/gitflash/snapshot_check.rb +60 -0
- data/lib/gitflash/snapshots.rb +26 -3
- data/lib/gitflash/state_capture.rb +26 -24
- data/lib/gitflash/terminal_text.rb +9 -0
- data/lib/gitflash/ui.rb +32 -4
- data/lib/gitflash/untracked_files.rb +19 -0
- data/lib/gitflash/usage_report.rb +66 -0
- data/lib/gitflash/version.rb +1 -1
- data/lib/gitflash/worktree.rb +4 -4
- data/schema/v1.json +39 -2
- metadata +16 -1
data/lib/gitflash/config.rb
CHANGED
|
@@ -4,29 +4,52 @@ require 'yaml'
|
|
|
4
4
|
|
|
5
5
|
module Gitflash
|
|
6
6
|
# Settings from `.gitflash.yml` in the main checkout and `~/.config/gitflash.yml`.
|
|
7
|
-
# Precedence: repository file, then user file, then built-in defaults
|
|
7
|
+
# Precedence: repository file, then user file, then built-in defaults; for `worktree_dir` the
|
|
8
|
+
# user file wins, since it decides where on the user's disk checkouts are written.
|
|
8
9
|
# Command-line flags are applied by the commands themselves and win over all of these.
|
|
9
10
|
# 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
|
|
11
|
-
|
|
11
|
+
Config = Data.define(:protected_patterns, :stale_days, :worktree_dir, :worktree_dir_from_repo,
|
|
12
|
+
:snapshot_exclude) do
|
|
13
|
+
# `root` is the main checkout; `home` is where the user file lives. The repository file wins
|
|
14
|
+
# key by key, except `protected`: both files add patterns, so a repository can protect more
|
|
15
|
+
# branches but never remove what the user protects; and `worktree_dir`, where the user file
|
|
16
|
+
# wins. A cloned repository's file may only place worktrees next to the main checkout.
|
|
12
17
|
def self.load(root:, home: Dir.home)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
new(protected_patterns:
|
|
17
|
-
|
|
18
|
+
user = read(File.join(home, ConfigKeys::USER_FILE))
|
|
19
|
+
repo = read(File.join(root, ConfigKeys::REPO_FILE), repository: true)
|
|
20
|
+
values = ConfigKeys::DEFAULTS.merge(user).merge(repo)
|
|
21
|
+
new(protected_patterns: both([user, repo], 'protected'), stale_days: values['stale_days'],
|
|
22
|
+
snapshot_exclude: both([user, repo], 'snapshot_exclude'), **worktree_dir_of(user, repo))
|
|
18
23
|
end
|
|
19
24
|
|
|
25
|
+
# Lists that both files add to
|
|
26
|
+
def self.both(layers, key)
|
|
27
|
+
layers.flat_map { |layer| layer.fetch(key, []) }.uniq
|
|
28
|
+
end
|
|
29
|
+
private_class_method :both
|
|
30
|
+
|
|
31
|
+
# The user file wins for worktree_dir; a value from the repository is checked again where
|
|
32
|
+
# it leads (see ConfigKeys.check_contained!)
|
|
33
|
+
def self.worktree_dir_of(user, repo)
|
|
34
|
+
from_repo = !user.key?('worktree_dir') && repo.key?('worktree_dir')
|
|
35
|
+
dir = from_repo ? repo['worktree_dir'] : user.fetch('worktree_dir', ConfigKeys::DEFAULT_WORKTREE_DIR)
|
|
36
|
+
{ worktree_dir: dir,
|
|
37
|
+
worktree_dir_from_repo: from_repo }
|
|
38
|
+
end
|
|
39
|
+
private_class_method :worktree_dir_of
|
|
40
|
+
|
|
20
41
|
def self.defaults
|
|
21
|
-
new(protected_patterns: [], stale_days: 30, worktree_dir: ConfigKeys::DEFAULT_WORKTREE_DIR
|
|
42
|
+
new(protected_patterns: [], stale_days: 30, worktree_dir: ConfigKeys::DEFAULT_WORKTREE_DIR,
|
|
43
|
+
worktree_dir_from_repo: false, snapshot_exclude: [])
|
|
22
44
|
end
|
|
23
45
|
|
|
24
|
-
def self.read(path)
|
|
46
|
+
def self.read(path, repository: false)
|
|
25
47
|
return {} unless File.file?(path)
|
|
26
48
|
|
|
27
49
|
reject_duplicate_keys!(path)
|
|
28
50
|
data = YAML.safe_load_file(path) || {}
|
|
29
51
|
validate!(data, path)
|
|
52
|
+
check_repository_dir!(data['worktree_dir'], path) if repository
|
|
30
53
|
data
|
|
31
54
|
rescue Psych::Exception, SystemCallError, IOError => e
|
|
32
55
|
raise invalid(path, "cannot be read (#{e.message.lines.first.strip})")
|
|
@@ -75,21 +98,51 @@ module Gitflash
|
|
|
75
98
|
end
|
|
76
99
|
private_class_method :check_types!
|
|
77
100
|
|
|
101
|
+
# A repository's file comes with the clone, so its worktree_dir must stay next to the main
|
|
102
|
+
# checkout: relative, no `~`, and not climbing above the main checkout's parent
|
|
103
|
+
def self.check_repository_dir!(template, path)
|
|
104
|
+
return if template.nil? || ConfigKeys.contained?(template)
|
|
105
|
+
|
|
106
|
+
raise invalid(path, "'worktree_dir' must be a relative path that stays next to the " \
|
|
107
|
+
'repository (set other places in ~/.config/gitflash.yml)')
|
|
108
|
+
end
|
|
109
|
+
private_class_method :check_repository_dir!
|
|
110
|
+
|
|
78
111
|
def self.invalid(path, problem)
|
|
79
112
|
UsageError.new("#{path} #{problem}", code: 'invalid_config')
|
|
80
113
|
end
|
|
81
114
|
private_class_method :invalid
|
|
82
115
|
|
|
83
|
-
# True when the branch name matches a configured pattern (shell-style
|
|
116
|
+
# True when the branch name matches a configured pattern (shell-style `*`, `?`, `[abc]` and
|
|
117
|
+
# `{a,b}`)
|
|
84
118
|
def protected?(name)
|
|
85
|
-
protected_patterns.any? { |pattern| File.fnmatch?(pattern, name) }
|
|
119
|
+
protected_patterns.any? { |pattern| File.fnmatch?(pattern, name, File::FNM_EXTGLOB) }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# True when an untracked file must stay out of snapshots: the pattern matches its path or
|
|
123
|
+
# its name (`*.pem`, `.env*`, `secrets/*`)
|
|
124
|
+
def snapshot_excluded?(path)
|
|
125
|
+
flags = File::FNM_PATHNAME | File::FNM_DOTMATCH
|
|
126
|
+
names = excluded_candidates(path)
|
|
127
|
+
snapshot_exclude.any? { |pattern| names.any? { |name| File.fnmatch?(pattern, name, flags) } }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The path, its name, and every directory above it: a pattern that matches a directory
|
|
131
|
+
# (`secrets`, `secrets/*`) covers everything below it, as in .gitignore
|
|
132
|
+
def excluded_candidates(path)
|
|
133
|
+
parts = path.split('/')
|
|
134
|
+
dirs = (1...parts.size).map { |count| parts.first(count).join('/') }
|
|
135
|
+
[path, File.basename(path), *dirs]
|
|
86
136
|
end
|
|
87
137
|
|
|
88
|
-
# Directory for a new worktree.
|
|
89
|
-
# is resolved against the main checkout. Slashes in a branch name stay as
|
|
138
|
+
# Directory for a new worktree. `%<repo>s` and `%<branch>s` are replaced as plain text; a
|
|
139
|
+
# relative path is resolved against the main checkout. Slashes in a branch name stay as
|
|
140
|
+
# folders.
|
|
90
141
|
def worktree_path(root:, branch:)
|
|
91
|
-
path =
|
|
92
|
-
File.expand_path(path, root)
|
|
142
|
+
path = worktree_dir.gsub('%<repo>s') { File.basename(root) }.gsub('%<branch>s') { branch }
|
|
143
|
+
path = File.expand_path(path, root)
|
|
144
|
+
ConfigKeys.check_contained!(path, root) if worktree_dir_from_repo
|
|
145
|
+
path
|
|
93
146
|
end
|
|
94
147
|
end
|
|
95
148
|
end
|
data/lib/gitflash/config_keys.rb
CHANGED
|
@@ -7,13 +7,73 @@ module Gitflash
|
|
|
7
7
|
USER_FILE = File.join('.config', 'gitflash.yml')
|
|
8
8
|
DEFAULT_WORKTREE_DIR = '../%<repo>s.worktrees/%<branch>s'
|
|
9
9
|
DEFAULTS = {
|
|
10
|
-
'protected' => [], 'stale_days' => 30, 'worktree_dir' => DEFAULT_WORKTREE_DIR
|
|
10
|
+
'protected' => [], 'stale_days' => 30, 'worktree_dir' => DEFAULT_WORKTREE_DIR,
|
|
11
|
+
'snapshot_exclude' => []
|
|
11
12
|
}.freeze
|
|
13
|
+
PLACEHOLDERS = ['%<repo>s', '%<branch>s'].freeze
|
|
14
|
+
MAX_TEMPLATE_LENGTH = 512
|
|
12
15
|
CHECKS = {
|
|
13
|
-
'protected' => [->(v) { v.is_a?(Array) && v.all?(
|
|
14
|
-
'a list of branch name patterns'],
|
|
16
|
+
'protected' => [->(v) { v.is_a?(Array) && v.all? { |pattern| branch_pattern?(pattern) } },
|
|
17
|
+
'a list of branch name patterns (no spaces, control characters, ~, ^ or :)'],
|
|
15
18
|
'stale_days' => [->(v) { v.is_a?(Integer) && v.positive? }, 'a positive whole number'],
|
|
16
|
-
'worktree_dir' => [->(v) {
|
|
19
|
+
'worktree_dir' => [->(v) { template?(v) },
|
|
20
|
+
'a path of up to 512 characters that uses only %<repo>s and %<branch>s'],
|
|
21
|
+
'snapshot_exclude' => [->(v) { v.is_a?(Array) && v.all?(String) },
|
|
22
|
+
'a list of file name patterns']
|
|
17
23
|
}.freeze
|
|
24
|
+
|
|
25
|
+
# Whether a template resolves inside the parent of the main checkout, for any repository and
|
|
26
|
+
# branch name (branch names cannot hold `..`)
|
|
27
|
+
def self.contained?(template)
|
|
28
|
+
return false if template.start_with?('/', '~')
|
|
29
|
+
|
|
30
|
+
root = '/parent/repo'
|
|
31
|
+
path = template.gsub('%<repo>s', 'repo').gsub('%<branch>s', 'branch')
|
|
32
|
+
File.expand_path(path, root).start_with?("#{File.dirname(root)}/")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# A pattern that can match a branch name: characters git refuses in branch names, such as a
|
|
36
|
+
# space at either end, would make it match nothing without any warning
|
|
37
|
+
def self.branch_pattern?(value)
|
|
38
|
+
return false unless value.is_a?(String) && !value.empty?
|
|
39
|
+
return false if value.match?(/[\x00-\x20\x7F~^:\\]/)
|
|
40
|
+
|
|
41
|
+
# `..` is never in a branch name; inside `{..,a}` it is only one of the alternatives
|
|
42
|
+
alternatives(value).any? { |pattern| !pattern.include?('..') }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The patterns `{a,b}` stands for, innermost braces first, as File::FNM_EXTGLOB reads them
|
|
46
|
+
def self.alternatives(pattern)
|
|
47
|
+
match = pattern.match(/\{([^{}]*)\}/)
|
|
48
|
+
return [pattern] unless match
|
|
49
|
+
|
|
50
|
+
match[1].split(',', -1).flat_map do |choice|
|
|
51
|
+
alternatives(match.pre_match + choice + match.post_match)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A path template: the two placeholders are the only `%` directives, so a width such as
|
|
56
|
+
# `%<repo>999999999s` can never make Ruby build a huge string
|
|
57
|
+
def self.template?(value)
|
|
58
|
+
return false unless value.is_a?(String)
|
|
59
|
+
return false if value.empty? || value.length > MAX_TEMPLATE_LENGTH
|
|
60
|
+
|
|
61
|
+
!PLACEHOLDERS.reduce(value) { |rest, placeholder| rest.gsub(placeholder, '') }.include?('%')
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# A repository's worktree_dir passed a check on the text alone; a symbolic link in the
|
|
65
|
+
# repository (`wt-link -> /elsewhere`) could still lead out, so the real location of the
|
|
66
|
+
# deepest existing directory on the way must stay next to the main checkout too
|
|
67
|
+
def self.check_contained!(path, root)
|
|
68
|
+
parent = File.realpath(File.dirname(root))
|
|
69
|
+
existing = path
|
|
70
|
+
existing = File.dirname(existing) until File.exist?(existing)
|
|
71
|
+
real = File.join(File.realpath(existing), path.delete_prefix(existing))
|
|
72
|
+
return if real.start_with?("#{parent}/")
|
|
73
|
+
|
|
74
|
+
raise UsageError.new("worktree_dir in #{REPO_FILE} leads to #{real}, outside " \
|
|
75
|
+
"#{parent}; set worktree_dir in ~/.config/gitflash.yml to use it",
|
|
76
|
+
code: 'invalid_config')
|
|
77
|
+
end
|
|
18
78
|
end
|
|
19
79
|
end
|
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
module Gitflash
|
|
4
4
|
# Raised when a destructive command runs without a terminal and without --yes.
|
|
5
|
-
# Carries the plan so the caller can review it and re-run with --yes
|
|
5
|
+
# Carries the plan so the caller can review it and re-run with --yes, or, for a change
|
|
6
|
+
# gitflash undo cannot revert, the token to pass to --confirm.
|
|
6
7
|
class ConfirmationRequired < Error
|
|
7
|
-
|
|
8
|
+
attr_reader :confirm_token
|
|
9
|
+
|
|
10
|
+
def initialize(message = nil, plan: nil, confirm_token: nil)
|
|
8
11
|
super(message, code: 'confirmation_required', plan: plan)
|
|
12
|
+
@confirm_token = confirm_token
|
|
9
13
|
end
|
|
10
14
|
|
|
11
15
|
def exit_code
|
|
@@ -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}"
|
|
@@ -1,30 +1,133 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'tmpdir'
|
|
7
|
+
|
|
3
8
|
module Gitflash
|
|
4
9
|
module Hook
|
|
5
10
|
# Marks the branches an agent just created as agent work (see Ownership), so
|
|
6
11
|
# `gitflash clean --agent` can find them. Runs in the PostToolUse hook, after the command:
|
|
7
12
|
# the branch exists then, and Claude Code only runs it when the command succeeded.
|
|
8
13
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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.
|
|
11
21
|
class BranchMarker
|
|
12
22
|
CREATED = 'branch: Created from'
|
|
13
23
|
RECENT_SECONDS = 120
|
|
24
|
+
STALE_RECORD_SECONDS = 24 * 60 * 60
|
|
14
25
|
|
|
15
|
-
def initialize(clock: -> { Time.now })
|
|
26
|
+
def initialize(clock: -> { Time.now }, state_dir: nil)
|
|
16
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)))
|
|
17
39
|
end
|
|
18
40
|
|
|
19
|
-
#
|
|
20
|
-
def call(command, cwd)
|
|
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)
|
|
21
44
|
created = CommandParser.new(cwd).creations(command)
|
|
22
|
-
created.
|
|
45
|
+
created.reject { |creation| already_there?(existed, creation) }
|
|
46
|
+
.select { |creation| mark(creation[:dir], creation[:branch]) }
|
|
23
47
|
.map { |creation| creation[:branch] }.uniq
|
|
24
48
|
end
|
|
25
49
|
|
|
26
50
|
private
|
|
27
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
|
+
|
|
28
131
|
def mark(dir, branch)
|
|
29
132
|
return false unless Dir.exist?(dir)
|
|
30
133
|
|
data/lib/gitflash/hook/claude.rb
CHANGED
|
@@ -29,21 +29,39 @@ module Gitflash
|
|
|
29
29
|
data = JSON.parse(input)
|
|
30
30
|
return after_command(data) if data['hook_event_name'] == POST_EVENT
|
|
31
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)
|
|
32
40
|
targets = targets(data)
|
|
33
41
|
return nil if targets.empty?
|
|
34
42
|
return deny(targets) if @mode == 'deny'
|
|
35
43
|
|
|
36
|
-
|
|
37
|
-
saved.
|
|
44
|
+
merged = merge_by_dir(targets)
|
|
45
|
+
saved = merged.filter_map { |target| save(target) }
|
|
46
|
+
# Asking is the user's protection: it must not depend on a snapshot having been saved.
|
|
47
|
+
# A command that runs where nothing could be saved is always reported to the agent.
|
|
48
|
+
output(saved, merged - saved.map(&:first))
|
|
38
49
|
end
|
|
39
50
|
|
|
40
|
-
|
|
51
|
+
# Recording is a side feature: when it fails, snapshots and the ask or deny decision still run
|
|
52
|
+
def record_branches(data)
|
|
53
|
+
return unless bash?(data)
|
|
54
|
+
|
|
55
|
+
@marker.record(command_of(data), data['cwd'] || Dir.pwd, data['tool_use_id'])
|
|
56
|
+
rescue StandardError => e
|
|
57
|
+
warn "gitflash hook: could not record branches (#{e.message})"
|
|
58
|
+
end
|
|
41
59
|
|
|
42
60
|
# PostToolUse: mark the branches the command created, and tell Claude
|
|
43
61
|
def after_command(data)
|
|
44
62
|
return nil unless data['tool_name'] == 'Bash'
|
|
45
63
|
|
|
46
|
-
marked = @marker.call(data
|
|
64
|
+
marked = @marker.call(command_of(data), data['cwd'] || Dir.pwd, data['tool_use_id'])
|
|
47
65
|
return nil if marked.empty?
|
|
48
66
|
|
|
49
67
|
context = "gitflash marked #{marked.join(', ')} as agent work, so " \
|
|
@@ -52,24 +70,27 @@ module Gitflash
|
|
|
52
70
|
{ hookSpecificOutput: { hookEventName: POST_EVENT, additionalContext: context } }
|
|
53
71
|
end
|
|
54
72
|
|
|
73
|
+
def bash?(data)
|
|
74
|
+
data['tool_name'] == 'Bash'
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def command_of(data)
|
|
78
|
+
data.dig('tool_input', 'command').to_s
|
|
79
|
+
end
|
|
80
|
+
|
|
55
81
|
def targets(data)
|
|
56
|
-
return [] unless data
|
|
82
|
+
return [] unless bash?(data)
|
|
57
83
|
|
|
58
|
-
|
|
59
|
-
CommandParser.new(data['cwd'] || Dir.pwd).targets(command)
|
|
84
|
+
CommandParser.new(data['cwd'] || Dir.pwd).targets(command_of(data))
|
|
60
85
|
end
|
|
61
86
|
|
|
62
87
|
# One snapshot per directory, covering every command run there
|
|
63
88
|
def merge_by_dir(targets)
|
|
64
|
-
targets.group_by(&:dir).map
|
|
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
|
|
89
|
+
targets.group_by(&:dir).map { |dir, group| CommandParser::Target.merge(dir, group) }
|
|
70
90
|
end
|
|
71
91
|
|
|
72
|
-
# [target, snapshot], or nil when the directory is not a git work tree
|
|
92
|
+
# [target, snapshot], or nil when the directory is not a git work tree or saving failed.
|
|
93
|
+
# A failure must not stop the ask decision, so it is warned about here and not raised.
|
|
73
94
|
def save(target)
|
|
74
95
|
return nil unless Dir.exist?(target.dir)
|
|
75
96
|
|
|
@@ -80,6 +101,9 @@ module Gitflash
|
|
|
80
101
|
scope: target.scope, branches: branches(target))
|
|
81
102
|
[target, snapshot]
|
|
82
103
|
end
|
|
104
|
+
rescue StandardError => e
|
|
105
|
+
warn "gitflash hook: could not save a snapshot in #{target.dir} (#{e.message})"
|
|
106
|
+
nil
|
|
83
107
|
end
|
|
84
108
|
|
|
85
109
|
# Full ref shortened by removing refs/heads/ only; `--short` changes when a tag has the name
|
|
@@ -87,17 +111,18 @@ module Gitflash
|
|
|
87
111
|
git('symbolic-ref', '--quiet', 'HEAD')&.delete_prefix(Repo::HEADS)
|
|
88
112
|
end
|
|
89
113
|
|
|
114
|
+
# The branches to save; nil (all of them) when the command does not name its branches, as
|
|
115
|
+
# in `xargs git branch -D < list`
|
|
90
116
|
def branches(target)
|
|
117
|
+
return nil if target.branches.empty?
|
|
118
|
+
|
|
91
119
|
target.branches.filter_map do |name|
|
|
92
120
|
name == :current ? current_branch : name
|
|
93
121
|
end
|
|
94
122
|
end
|
|
95
123
|
|
|
96
|
-
def output(saved)
|
|
97
|
-
|
|
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`).'
|
|
124
|
+
def output(saved, unsaved)
|
|
125
|
+
context = Messages.context(saved, unsaved)
|
|
101
126
|
specific = { hookEventName: EVENT, additionalContext: context }
|
|
102
127
|
if @mode == 'ask'
|
|
103
128
|
specific[:permissionDecision] = 'ask'
|
|
@@ -108,12 +133,8 @@ module Gitflash
|
|
|
108
133
|
end
|
|
109
134
|
|
|
110
135
|
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
136
|
{ hookSpecificOutput: { hookEventName: EVENT, permissionDecision: 'deny',
|
|
116
|
-
permissionDecisionReason:
|
|
137
|
+
permissionDecisionReason: Messages.denial(targets) } }
|
|
117
138
|
end
|
|
118
139
|
|
|
119
140
|
# Stripped stdout of a git command, or nil when it fails
|