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/hook/rules.rb
CHANGED
|
@@ -7,10 +7,15 @@ module Gitflash
|
|
|
7
7
|
# { scope:, branches:, dir: } where branches may contain :current for the checked-out one.
|
|
8
8
|
module Rules
|
|
9
9
|
FULL = %w[branches head worktree].freeze
|
|
10
|
+
# A command too deeply nested to read (see CommandParser#nested): save everything
|
|
11
|
+
UNREAD = :unread
|
|
10
12
|
|
|
11
13
|
module_function
|
|
12
14
|
|
|
13
15
|
def for(subcommand, args, dir)
|
|
16
|
+
# No branch names: the snapshot keeps every branch
|
|
17
|
+
return { scope: FULL } if subcommand == UNREAD
|
|
18
|
+
|
|
14
19
|
rule = RULES[subcommand]
|
|
15
20
|
rule&.call(args, dir)
|
|
16
21
|
end
|
|
@@ -34,16 +39,27 @@ module Gitflash
|
|
|
34
39
|
|
|
35
40
|
def path_arg?(args, dir)
|
|
36
41
|
args.reject { |arg| arg.start_with?('-') }.any? do |arg|
|
|
37
|
-
File.exist?(
|
|
42
|
+
File.exist?(Paths.expand(arg, dir))
|
|
38
43
|
end
|
|
39
44
|
end
|
|
40
45
|
|
|
41
|
-
|
|
46
|
+
# `git clean` without -f removes files too when clean.requireForce is false
|
|
47
|
+
def clean(args, dir)
|
|
42
48
|
flags = args.select { |arg| arg.start_with?('-') }
|
|
43
49
|
short = short_flags(args)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
50
|
+
removes = any?(flags, %w[--force --interactive]) || short.match?(/[fi]/) ||
|
|
51
|
+
!require_force?(dir)
|
|
52
|
+
dry_run = flags.include?('--dry-run') || short.include?('n')
|
|
53
|
+
return nil unless removes && !dry_run
|
|
54
|
+
|
|
55
|
+
# -x and -X delete ignored files too
|
|
56
|
+
{ scope: %w[worktree], ignored: short.match?(/[xX]/) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def require_force?(dir)
|
|
60
|
+
stdout, _stderr, success = Git::BashCommand.capture('git', '-C', dir, 'config', '--bool',
|
|
61
|
+
'clean.requireForce')
|
|
62
|
+
!(success && stdout.strip == 'false')
|
|
47
63
|
end
|
|
48
64
|
|
|
49
65
|
def switch(args, _dir)
|
|
@@ -59,12 +75,19 @@ module Gitflash
|
|
|
59
75
|
end
|
|
60
76
|
|
|
61
77
|
def branch(args, _dir)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
78
|
+
changes = any?(args, %w[--delete --force --move --copy]) ||
|
|
79
|
+
short_flags(args).match?(/[dDfmMcC]/)
|
|
80
|
+
return nil unless changes
|
|
81
|
+
|
|
82
|
+
names = args.reject { |arg| arg.start_with?('-') }
|
|
83
|
+
# A name the shell or xargs fills in (`$b`, `{}`) is unknown: save every branch
|
|
84
|
+
names = [] if names.any? { |name| Paths.unresolved?(name) || name.include?('{}') }
|
|
85
|
+
{ scope: %w[branches], branches: names }
|
|
65
86
|
end
|
|
66
87
|
|
|
67
88
|
def update_ref(args, _dir)
|
|
89
|
+
# `--stdin` reads the refs to delete from input the text does not show: save all branches
|
|
90
|
+
return { scope: %w[branches] } if args.include?('--stdin')
|
|
68
91
|
return nil unless args.include?('-d')
|
|
69
92
|
|
|
70
93
|
heads = args.select { |arg| arg.start_with?('refs/heads/') }
|
|
@@ -79,12 +102,13 @@ module Gitflash
|
|
|
79
102
|
any?(args, %w[--abort --quit]) ? { scope: %w[worktree] } : nil
|
|
80
103
|
end
|
|
81
104
|
|
|
82
|
-
# `git worktree remove
|
|
105
|
+
# `git worktree remove PATH` deletes that worktree's ignored files, and with --force its
|
|
106
|
+
# uncommitted ones too
|
|
83
107
|
def worktree(args, dir)
|
|
84
|
-
return nil unless args.first == 'remove'
|
|
108
|
+
return nil unless args.first == 'remove'
|
|
85
109
|
|
|
86
110
|
path = args[1..].reject { |arg| arg.start_with?('-') }.first
|
|
87
|
-
path && { scope: %w[worktree], dir:
|
|
111
|
+
path && { scope: %w[worktree], dir: Paths.change_dir(path, dir, dir), ignored: true }
|
|
88
112
|
end
|
|
89
113
|
|
|
90
114
|
RULES = {
|
|
@@ -102,7 +126,7 @@ module Gitflash
|
|
|
102
126
|
'revert' => method(:abort),
|
|
103
127
|
'am' => method(:abort),
|
|
104
128
|
'worktree' => method(:worktree)
|
|
105
|
-
}.freeze
|
|
129
|
+
}.merge(HistoryRules::RULES).freeze
|
|
106
130
|
end
|
|
107
131
|
end
|
|
108
132
|
end
|
|
@@ -44,16 +44,33 @@ module Gitflash
|
|
|
44
44
|
def read(path)
|
|
45
45
|
return {} unless File.exist?(path)
|
|
46
46
|
|
|
47
|
-
JSON.parse(File.read(path))
|
|
47
|
+
check_shape!(path, JSON.parse(File.read(path)))
|
|
48
48
|
rescue JSON::ParserError => e
|
|
49
49
|
raise Error.new("#{path} is not valid JSON: #{e.message}", code: 'invalid_settings')
|
|
50
|
+
rescue SystemCallError => e
|
|
51
|
+
raise Error.new("#{path} cannot be read: #{e.message}", code: 'invalid_settings')
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def check_shape!(path, data)
|
|
55
|
+
# A missing `hooks` key is a file without hooks yet; a present one must be an object
|
|
56
|
+
# whose events are lists of objects, as Claude Code reads them
|
|
57
|
+
return data if data.is_a?(Hash) && (!data.key?('hooks') || hooks_shape?(data['hooks']))
|
|
58
|
+
|
|
59
|
+
raise Error.new("#{path} does not hold a JSON object whose `hooks` maps each event to " \
|
|
60
|
+
'a list of objects', code: 'invalid_settings')
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def hooks_shape?(hooks)
|
|
64
|
+
hooks.is_a?(Hash) &&
|
|
65
|
+
hooks.values.all? { |groups| groups.is_a?(Array) && groups.all?(Hash) }
|
|
50
66
|
end
|
|
51
67
|
|
|
52
68
|
# The gitflash hook entry for an event (PreToolUse by default) in parsed settings, or nil.
|
|
53
69
|
# Only groups whose matcher covers the Bash tool count: a gitflash command under another
|
|
54
70
|
# matcher (Edit, say) never runs for Bash commands.
|
|
55
71
|
def entry(settings, event = 'PreToolUse')
|
|
56
|
-
|
|
72
|
+
hooks = settings['hooks']
|
|
73
|
+
groups = hooks.is_a?(Hash) ? Array(hooks[event]) : []
|
|
57
74
|
bash = groups.select { |group| bash_matcher?(group['matcher']) }
|
|
58
75
|
bash.flat_map { |group| Array(group['hooks']) }
|
|
59
76
|
.find { |hook| hook['command'].to_s.match?(PATTERN) }
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitflash
|
|
4
|
+
module Hook
|
|
5
|
+
# Where quoted text and substitutions end in a shell command line (see ShellText)
|
|
6
|
+
module ShellQuotes
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# The first backtick at or after index that no backslash escapes, or the end of the line
|
|
10
|
+
def closing_backtick(line, index)
|
|
11
|
+
while index < line.length
|
|
12
|
+
return index if line[index] == '`'
|
|
13
|
+
|
|
14
|
+
index += line[index] == '\\' ? 2 : 1
|
|
15
|
+
end
|
|
16
|
+
line.length
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The `)` that closes a parenthesis opened before index, skipping quoted text and nested
|
|
20
|
+
# substitutions and subshells, or the end of the line
|
|
21
|
+
def closing_paren(line, index)
|
|
22
|
+
while index < line.length
|
|
23
|
+
return index if line[index] == ')'
|
|
24
|
+
|
|
25
|
+
index = after_in_parens(line, index)
|
|
26
|
+
end
|
|
27
|
+
line.length
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def after_in_parens(line, index)
|
|
31
|
+
case line[index]
|
|
32
|
+
when '\\' then index + 2
|
|
33
|
+
when "'" then closing_single(line, index + 1) + 1
|
|
34
|
+
when '"' then closing_double(line, index + 1) + 1
|
|
35
|
+
when '`' then closing_backtick(line, index + 1) + 1
|
|
36
|
+
when '(' then closing_paren(line, index + 1) + 1
|
|
37
|
+
else index + 1
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def closing_single(line, index)
|
|
42
|
+
line.index("'", index) || line.length
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The `"` that closes a double-quoted string opened before index
|
|
46
|
+
def closing_double(line, index)
|
|
47
|
+
while index < line.length
|
|
48
|
+
return index if line[index] == '"'
|
|
49
|
+
|
|
50
|
+
index = after_in_double(line, index)
|
|
51
|
+
end
|
|
52
|
+
line.length
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def after_in_double(line, index)
|
|
56
|
+
return index + 2 if line[index] == '\\'
|
|
57
|
+
return closing_backtick(line, index + 1) + 1 if line[index] == '`'
|
|
58
|
+
return closing_paren(line, index + 2) + 1 if line[index, 2] == '$('
|
|
59
|
+
|
|
60
|
+
index + 1
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitflash
|
|
4
|
+
module Hook
|
|
5
|
+
# Reads a shell command line as text, without running it: where commands end, and which
|
|
6
|
+
# commands run inside `$(...)`, `<(...)` and backticks. Quotes are followed as the shell
|
|
7
|
+
# does: nothing runs inside single quotes, `$(...)` and backticks run inside double quotes,
|
|
8
|
+
# and a backslash keeps the next character (outside single quotes).
|
|
9
|
+
module ShellText
|
|
10
|
+
SEPARATORS = ";&|\n"
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# The command line split on ; & | and newlines outside quotes and substitutions
|
|
15
|
+
def segments(line)
|
|
16
|
+
scan(line).first
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The scripts the shell runs to build the line: the text inside `$(...)`, `<(...)`,
|
|
20
|
+
# `>(...)` and backticks. Nested ones are found when the caller reads each script in turn.
|
|
21
|
+
def substitutions(line)
|
|
22
|
+
scan(line).last
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# [segments, scripts] in one pass over the line
|
|
26
|
+
def scan(line)
|
|
27
|
+
parts = [+'']
|
|
28
|
+
scripts = []
|
|
29
|
+
index = 0
|
|
30
|
+
quote = nil
|
|
31
|
+
index, quote = step(line, index, quote, parts, scripts) while index < line.length
|
|
32
|
+
[parts.reject { |part| part.strip.empty? }, scripts]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Reads what starts at index and returns where to go on, with the quote then open
|
|
36
|
+
def step(line, index, quote, parts, scripts)
|
|
37
|
+
char = line[index]
|
|
38
|
+
return [index + 1, close_single(char, parts)] if quote == "'"
|
|
39
|
+
return take(line, index, index + 2, parts) + [quote] if char == '\\'
|
|
40
|
+
return [comment_end(line, index), nil] if quote.nil? && comment?(line, index)
|
|
41
|
+
|
|
42
|
+
substituted(line, index, quote, parts, scripts) || plain(char, index, quote, parts)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Takes a substitution that opens at index, or returns nil
|
|
46
|
+
def substituted(line, index, quote, parts, scripts)
|
|
47
|
+
script = substitution(line, index, quote)
|
|
48
|
+
return nil unless script
|
|
49
|
+
|
|
50
|
+
scripts << line[script.first...script.last]
|
|
51
|
+
take(line, index, script.last + 1, parts) + [quote]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# A `#` that starts a word outside quotes starts a comment, which runs to the end of the
|
|
55
|
+
# line; an apostrophe in it (`# don't`) must not open a quote
|
|
56
|
+
def comment?(line, index)
|
|
57
|
+
line[index] == '#' && (index.zero? || line[index - 1].match?(/[\s;&|()]/))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def comment_end(line, index)
|
|
61
|
+
line.index("\n", index) || line.length
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def close_single(char, parts)
|
|
65
|
+
parts.last << char
|
|
66
|
+
char == "'" ? nil : "'"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def plain(char, index, quote, parts)
|
|
70
|
+
if !quote && SEPARATORS.include?(char)
|
|
71
|
+
parts << +''
|
|
72
|
+
return [index + 1, nil]
|
|
73
|
+
end
|
|
74
|
+
parts.last << char
|
|
75
|
+
[index + 1, next_quote(char, quote)]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def next_quote(char, quote)
|
|
79
|
+
return (char == '"' ? nil : quote) if quote
|
|
80
|
+
return char if ["'", '"'].include?(char)
|
|
81
|
+
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def take(line, from, to, parts)
|
|
86
|
+
parts.last << line[from...to].to_s
|
|
87
|
+
[to]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# [script start, closing index] of a substitution opening at index, or nil. `<(...)` and
|
|
91
|
+
# `>(...)` only open outside quotes; `$(...)` and backticks also inside double quotes.
|
|
92
|
+
def substitution(line, index, quote)
|
|
93
|
+
if line[index] == '`'
|
|
94
|
+
[index + 1, ShellQuotes.closing_backtick(line, index + 1)]
|
|
95
|
+
elsif line[index, 2] == '$(' || (quote.nil? && line[index, 2].match?(/\A[<>]\(/))
|
|
96
|
+
[index + 2, ShellQuotes.closing_paren(line, index + 2)]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitflash
|
|
4
|
+
module Hook
|
|
5
|
+
# Commands that run the command after them: `sudo -u me git ...`, `timeout 5 git ...`,
|
|
6
|
+
# `xargs git ...`. Strips them, with their options, so the parser sees the git command.
|
|
7
|
+
module Wrappers
|
|
8
|
+
# Wrapper => its options that take a value as the next word
|
|
9
|
+
OPTIONS = {
|
|
10
|
+
'sudo' => %w[-u -g -h -p -C -D -r -t -U -T --user --group --host --prompt --chdir],
|
|
11
|
+
'env' => %w[-u -C -S --unset --chdir --split-string],
|
|
12
|
+
'command' => [], 'exec' => %w[-a], 'time' => %w[-f -o --format --output],
|
|
13
|
+
'nohup' => [], 'nice' => %w[-n --adjustment], 'ionice' => %w[-c -n -p -P -u],
|
|
14
|
+
'stdbuf' => %w[-i -o -e], 'chronic' => [], 'caffeinate' => %w[-t -w],
|
|
15
|
+
'timeout' => %w[-k -s --kill-after --signal],
|
|
16
|
+
# -i, -l and -e take only an attached value (`-i{}`), so they are not listed
|
|
17
|
+
'xargs' => %w[-I -n -P -L -d -E -s -a --arg-file --delimiter --max-args --max-procs]
|
|
18
|
+
}.freeze
|
|
19
|
+
# Wrappers whose first word after the options is not the command (`timeout 5 git ...`)
|
|
20
|
+
LEADING_ARGUMENT = %w[timeout].freeze
|
|
21
|
+
CHDIR_OPTIONS = %w[-C -D --chdir].freeze
|
|
22
|
+
FIND_EXEC = %w[-exec -execdir -ok -okdir].freeze
|
|
23
|
+
FIND_END = [';', '\;', '+'].freeze
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# The words without leading wrappers and their options, and the directory `sudo -D` or
|
|
28
|
+
# `env -C` switch to (nil when none does)
|
|
29
|
+
def strip(words)
|
|
30
|
+
chdir = nil
|
|
31
|
+
while (options = OPTIONS[File.basename(words.first.to_s)])
|
|
32
|
+
name = File.basename(words.first)
|
|
33
|
+
words, dir = drop_options(words.drop(1), options)
|
|
34
|
+
chdir = dir || chdir
|
|
35
|
+
words = words.drop(1) if LEADING_ARGUMENT.include?(name)
|
|
36
|
+
end
|
|
37
|
+
[words, chdir]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Drops options (and the values of those that take one) up to the command
|
|
41
|
+
def drop_options(words, with_value)
|
|
42
|
+
chdir = nil
|
|
43
|
+
while (word = words.first)&.start_with?('-')
|
|
44
|
+
words = words.drop(1)
|
|
45
|
+
break if word == '--'
|
|
46
|
+
next unless with_value.include?(word)
|
|
47
|
+
|
|
48
|
+
chdir = words.first if CHDIR_OPTIONS.include?(word)
|
|
49
|
+
words = words.drop(1)
|
|
50
|
+
end
|
|
51
|
+
[words, chdir]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The command `find ... -exec CMD ... ;` runs, or nil
|
|
55
|
+
def find_exec(words)
|
|
56
|
+
return nil unless File.basename(words.first.to_s) == 'find'
|
|
57
|
+
|
|
58
|
+
start = words.index { |word| FIND_EXEC.include?(word) }
|
|
59
|
+
return nil unless start
|
|
60
|
+
|
|
61
|
+
command = words[(start + 1)..].take_while { |word| !FIND_END.include?(word) }
|
|
62
|
+
.reject { |word| word == '{}' }
|
|
63
|
+
command.empty? ? nil : command
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/gitflash/hook_cli.rb
CHANGED
|
@@ -14,8 +14,12 @@ module Gitflash
|
|
|
14
14
|
true
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
TERMINAL_NOTE = 'gitflash hook claude is run by Claude Code, not by hand. Use ' \
|
|
18
|
+
'`gitflash hook install` to set it up and `gitflash hook status` to check it.'
|
|
19
|
+
|
|
17
20
|
desc 'claude',
|
|
18
|
-
'Run as a Claude Code hook (PreToolUse and PostToolUse; reads the input on stdin)'
|
|
21
|
+
'Run as a Claude Code hook (PreToolUse and PostToolUse; reads the input on stdin)',
|
|
22
|
+
hide: true
|
|
19
23
|
long_desc <<~TEXT
|
|
20
24
|
Reads the Claude Code hook input on stdin. When the Bash command can discard work git
|
|
21
25
|
cannot restore (for example reset --hard, clean -f, checkout -- ., branch -D, stash drop),
|
|
@@ -29,14 +33,33 @@ module Gitflash
|
|
|
29
33
|
branch NAME, worktree add) as agent work, so `gitflash clean --agent` can find them. A branch
|
|
30
34
|
is only marked when it is new (its reflog says it was created a moment ago) and unmarked.
|
|
31
35
|
|
|
32
|
-
|
|
36
|
+
Not meant to be run by hand: in a terminal it only prints a note. Errors are printed to
|
|
37
|
+
stderr and the hook exits with 0. In snapshot mode the command then runs; in ask mode the
|
|
38
|
+
user is asked and in deny mode the command is blocked, so an error never turns the
|
|
39
|
+
protection off. If even that answer cannot be written, ask and deny mode exit with 2,
|
|
40
|
+
which makes Claude Code block the command.
|
|
33
41
|
TEXT
|
|
34
42
|
option :mode, type: :string, default: 'snapshot', enum: Hook::Claude::MODES
|
|
35
43
|
def claude
|
|
36
|
-
|
|
44
|
+
return puts(TERMINAL_NOTE) if $stdin.tty?
|
|
45
|
+
|
|
46
|
+
output = hook_output(options[:mode], $stdin.read)
|
|
37
47
|
puts JSON.generate(output) if output
|
|
38
48
|
rescue StandardError => e
|
|
39
49
|
warn "gitflash hook: #{e.message}"
|
|
50
|
+
# No decision could be written: exit 2 makes Claude Code block the command in ask and
|
|
51
|
+
# deny mode instead of running it unprotected
|
|
52
|
+
exit 2 unless options[:mode] == 'snapshot'
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
no_commands do
|
|
56
|
+
# The hook's output; on an error, what Hook::Failure decides for the mode
|
|
57
|
+
def hook_output(mode, input)
|
|
58
|
+
Hook::Claude.new(mode: mode).call(input)
|
|
59
|
+
rescue StandardError => e
|
|
60
|
+
warn "gitflash hook: #{e.message}"
|
|
61
|
+
Hook::Failure.output(mode, e, input)
|
|
62
|
+
end
|
|
40
63
|
end
|
|
41
64
|
|
|
42
65
|
desc 'install', 'Register the gitflash hook in Claude Code settings'
|
|
@@ -52,13 +75,20 @@ module Gitflash
|
|
|
52
75
|
applies to sessions started there
|
|
53
76
|
--scope user ~/.claude/settings.json; applies to every session
|
|
54
77
|
|
|
78
|
+
--mode snapshot|ask|deny sets what the hook does (see `gitflash hook help claude`). Without
|
|
79
|
+
--mode an installed hook keeps its mode. A mode that protects less than the installed one
|
|
80
|
+
(deny, then ask, then snapshot) is refused unless you pass --downgrade.
|
|
81
|
+
|
|
55
82
|
Claude Code reloads settings files, so running sessions pick the hook up without a restart.
|
|
56
83
|
Check the result with `gitflash hook status`.
|
|
57
84
|
|
|
58
85
|
Accepts the global --json and --dry-run options after the subcommand: gitflash hook install --json
|
|
59
86
|
TEXT
|
|
60
87
|
option :scope, type: :string, default: 'local', enum: Commands::HookInstall::SCOPES
|
|
61
|
-
option :mode, type: :string,
|
|
88
|
+
option :mode, type: :string, enum: Hook::Claude::MODES,
|
|
89
|
+
desc: 'snapshot, ask or deny (default: the installed mode, else snapshot)'
|
|
90
|
+
option :downgrade, type: :boolean,
|
|
91
|
+
desc: 'Allow a --mode that protects less than the installed one'
|
|
62
92
|
def install
|
|
63
93
|
run_command('hook install', Commands::HookInstall, repo_required: options[:scope] != 'user')
|
|
64
94
|
end
|
data/lib/gitflash/protection.rb
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Gitflash
|
|
4
|
-
# Branches gitflash never deletes: the current and default branch,
|
|
5
|
-
#
|
|
4
|
+
# Branches gitflash never deletes: the current and default branch, the usual long-lived
|
|
5
|
+
# branches (`main`, `master`, `develop`, `development`, `trunk`, `release/*`), names matching
|
|
6
|
+
# `protected` in the settings, and any branch checked out in a worktree. The long-lived names
|
|
7
|
+
# count even when the default branch is something else or unknown: in a git-flow repository
|
|
8
|
+
# `develop` is merged into `main` after every release and would otherwise look done.
|
|
6
9
|
class Protection
|
|
7
|
-
NAMES = %w[main master].freeze
|
|
10
|
+
NAMES = %w[main master develop development trunk].freeze
|
|
11
|
+
PATTERNS = %w[release/*].freeze
|
|
8
12
|
|
|
9
13
|
def initialize(config:, checked_out: [])
|
|
10
14
|
@config = config
|
|
@@ -12,8 +16,12 @@ module Gitflash
|
|
|
12
16
|
end
|
|
13
17
|
|
|
14
18
|
def protected?(branch)
|
|
15
|
-
branch.current? || branch.default? ||
|
|
19
|
+
branch.current? || branch.default? || long_lived?(branch.name) ||
|
|
16
20
|
@config.protected?(branch.name) || @checked_out.include?(branch.name)
|
|
17
21
|
end
|
|
22
|
+
|
|
23
|
+
def long_lived?(name)
|
|
24
|
+
NAMES.include?(name) || PATTERNS.any? { |pattern| File.fnmatch?(pattern, name) }
|
|
25
|
+
end
|
|
18
26
|
end
|
|
19
27
|
end
|
data/lib/gitflash/repo.rb
CHANGED
|
@@ -39,12 +39,13 @@ module Gitflash
|
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
def toplevel
|
|
42
|
-
@bash.exec('git', 'rev-parse', '--show-toplevel').
|
|
42
|
+
@bash.exec('git', 'rev-parse', '--show-toplevel').delete_suffix("\n")
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
# Root of the main checkout; differs from toplevel inside a linked worktree
|
|
46
46
|
def main_root
|
|
47
|
-
common = @bash.exec('git', 'rev-parse', '--path-format=absolute',
|
|
47
|
+
common = @bash.exec('git', 'rev-parse', '--path-format=absolute',
|
|
48
|
+
'--git-common-dir').delete_suffix("\n")
|
|
48
49
|
File.basename(common) == '.git' ? File.dirname(common) : toplevel
|
|
49
50
|
end
|
|
50
51
|
|
data/lib/gitflash/snapshot.rb
CHANGED
|
@@ -12,9 +12,10 @@ module Gitflash
|
|
|
12
12
|
# worktree commit whose tree is the working tree including untracked files (not
|
|
13
13
|
# ignored ones), nil when the tree was clean
|
|
14
14
|
# skipped_files untracked files left out because they were too large
|
|
15
|
+
# excluded_files untracked files left out because `snapshot_exclude` matches them
|
|
15
16
|
Snapshot = Data.define(
|
|
16
17
|
:id, :created_at, :reason, :scope, :worktree_path, :head, :branches, :stashes,
|
|
17
|
-
:index, :worktree, :skipped_files
|
|
18
|
+
:index, :worktree, :skipped_files, :excluded_files
|
|
18
19
|
) do
|
|
19
20
|
def self.from_h(hash)
|
|
20
21
|
new(
|
|
@@ -22,10 +23,16 @@ module Gitflash
|
|
|
22
23
|
scope: hash['scope'], worktree_path: hash['worktree_path'],
|
|
23
24
|
head: { branch: hash.dig('head', 'branch'), sha: hash.dig('head', 'sha') },
|
|
24
25
|
branches: hash['branches'] || {}, stashes: hash['stashes'] || [],
|
|
25
|
-
index: hash['index'], worktree: hash['worktree'],
|
|
26
|
+
index: hash['index'], worktree: hash['worktree'],
|
|
27
|
+
skipped_files: hash['skipped_files'] || [], excluded_files: hash['excluded_files'] || []
|
|
26
28
|
)
|
|
27
29
|
end
|
|
28
30
|
|
|
31
|
+
# Untracked files this snapshot cannot bring back
|
|
32
|
+
def unsaved_files
|
|
33
|
+
skipped_files + excluded_files
|
|
34
|
+
end
|
|
35
|
+
|
|
29
36
|
def scope?(part)
|
|
30
37
|
scope.include?(part)
|
|
31
38
|
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitflash
|
|
4
|
+
# Checks a snapshot read from a ref before gitflash trusts it. Anyone with write access to the
|
|
5
|
+
# repository can create such a ref, and its fields go into git commands and paths.
|
|
6
|
+
module SnapshotCheck
|
|
7
|
+
SHA = /\A\h{40}(?:\h{24})?\z/
|
|
8
|
+
# What git accepts in a branch name, roughly: no control characters, spaces, `~^:?*[\`, `..`
|
|
9
|
+
# or `@{`, no leading `-` or `/`, no `//`, no trailing `/`, `.` or `.lock`
|
|
10
|
+
BRANCH = %r{\A(?!-|/)(?!.*(?:\.\.|@\{|//))[^\x00-\x20\x7F~^:?*\[\\]+(?<![/.])(?<!\.lock)\z}
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Why the snapshot read from ref cannot be trusted, or nil
|
|
15
|
+
def problem(ref, snapshot)
|
|
16
|
+
shape_problem(snapshot) || field_problem(ref, snapshot)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Fields of the wrong type, which the other checks could not even read
|
|
20
|
+
def shape_problem(snapshot)
|
|
21
|
+
return 'branches is not an object' unless snapshot.branches.is_a?(Hash)
|
|
22
|
+
return 'stashes is not a list' unless snapshot.stashes.is_a?(Array)
|
|
23
|
+
return 'scope is not a list of names' unless snapshot.scope.is_a?(Array) &&
|
|
24
|
+
snapshot.scope.all?(String)
|
|
25
|
+
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def field_problem(ref, snapshot)
|
|
30
|
+
expected = Snapshots::REF_PREFIX + snapshot.id.to_s
|
|
31
|
+
return 'its name does not match its id' unless ref == expected
|
|
32
|
+
return 'a commit id is not a full hex SHA' unless snapshot.object_ids.all? { |id| sha?(id) }
|
|
33
|
+
return 'a branch name is not valid' unless branch_names(snapshot).all? { |n| branch?(n) }
|
|
34
|
+
return 'worktree_path is not an absolute path' unless path?(snapshot.worktree_path)
|
|
35
|
+
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# BRANCH, and each slash-separated part non-empty, not starting with `.` and not ending in
|
|
40
|
+
# `.lock`; `@` alone is not a branch name either
|
|
41
|
+
def branch?(name)
|
|
42
|
+
name.match?(BRANCH) && name != '@' && name.split('/', -1).all? do |part|
|
|
43
|
+
!part.empty? && !part.start_with?('.') && !part.end_with?('.lock')
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def sha?(id)
|
|
48
|
+
id.is_a?(String) && id.match?(SHA)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def branch_names(snapshot)
|
|
52
|
+
(snapshot.branches.keys + [snapshot.head[:branch]]).compact.map(&:to_s)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# nil for snapshots from before worktree_path was recorded
|
|
56
|
+
def path?(path)
|
|
57
|
+
path.nil? || (path.is_a?(String) && path.start_with?('/') && !path.match?(/[\x00-\x1F]/))
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
data/lib/gitflash/snapshots.rb
CHANGED
|
@@ -31,11 +31,34 @@ module Gitflash
|
|
|
31
31
|
store(Snapshot.new(**identity, reason: reason, **state))
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
# Snapshots, newest first
|
|
34
|
+
# Snapshots, newest first. A ref whose message is not a snapshot is skipped with a warning,
|
|
35
|
+
# so one damaged ref cannot stop every command that lists or takes snapshots.
|
|
36
|
+
# Ordered by the time inside each snapshot, not by ref name, so a ref named to sort first
|
|
37
|
+
# cannot become the default for `undo`.
|
|
35
38
|
def list
|
|
36
|
-
git('for-each-ref', '--
|
|
39
|
+
git('for-each-ref', '--format=%(refname)%00%(contents:subject)', REF_PREFIX)
|
|
37
40
|
.each_line(chomp: true).reject(&:empty?)
|
|
38
|
-
.
|
|
41
|
+
.filter_map { |line| parse(*line.split("\0", 2)) }
|
|
42
|
+
.sort_by { |snapshot| [Time.iso8601(snapshot.created_at), snapshot.id] }.reverse
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def parse(ref, message)
|
|
46
|
+
data = JSON.parse(message.to_s)
|
|
47
|
+
raise TypeError, 'not an object' unless data.is_a?(Hash)
|
|
48
|
+
|
|
49
|
+
checked(ref, Snapshot.from_h(data))
|
|
50
|
+
rescue JSON::ParserError, TypeError, ArgumentError => e
|
|
51
|
+
warn "gitflash: skipping #{TerminalText.line(ref)}, which is not a readable snapshot " \
|
|
52
|
+
"(#{e.message.lines.first&.strip})"
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def checked(ref, snapshot)
|
|
57
|
+
Time.iso8601(snapshot.created_at.to_s)
|
|
58
|
+
problem = SnapshotCheck.problem(ref, snapshot)
|
|
59
|
+
raise TypeError, problem if problem
|
|
60
|
+
|
|
61
|
+
snapshot
|
|
39
62
|
end
|
|
40
63
|
|
|
41
64
|
def find(id)
|