gitflash 0.7.0.beta2 → 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 +37 -0
- data/README.md +15 -11
- data/command_descriptions.yml +21 -7
- data/lib/gitflash/atomic_file.rb +40 -0
- data/lib/gitflash/cli.rb +27 -4
- data/lib/gitflash/command_runner.rb +12 -1
- data/lib/gitflash/commands/clean.rb +29 -1
- data/lib/gitflash/commands/gc.rb +19 -4
- data/lib/gitflash/commands/hook_install.rb +66 -12
- data/lib/gitflash/commands/mark.rb +9 -3
- data/lib/gitflash/commands/snapshot_create.rb +6 -4
- data/lib/gitflash/commands/undo.rb +18 -8
- data/lib/gitflash/commands/worktree_command.rb +17 -3
- data/lib/gitflash/commands/worktree_remove.rb +14 -5
- data/lib/gitflash/config.rb +63 -13
- data/lib/gitflash/config_keys.rb +52 -4
- data/lib/gitflash/confirmation_required.rb +6 -2
- data/lib/gitflash/hook/claude.rb +17 -12
- data/lib/gitflash/hook/command_parser.rb +91 -68
- 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 +28 -2
- data/lib/gitflash/hook/paths.rb +47 -0
- data/lib/gitflash/hook/rules.rb +31 -9
- 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 +27 -4
- data/lib/gitflash/protection.rb +12 -4
- 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 +18 -21
- data/lib/gitflash/ui.rb +31 -3
- data/lib/gitflash/untracked_files.rb +19 -0
- data/lib/gitflash/usage_report.rb +66 -0
- data/lib/gitflash/version.rb +1 -1
- data/schema/v1.json +39 -2
- metadata +14 -1
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'shellwords'
|
|
4
|
+
|
|
5
|
+
module Gitflash
|
|
6
|
+
module Hook
|
|
7
|
+
# The words of one command: without subshell brackets, redirections, keywords like `then`,
|
|
8
|
+
# environment assignments and wrappers like `sudo -u me`
|
|
9
|
+
class CommandWords
|
|
10
|
+
# Shell words that can come before a command: `if git ...`, `then git ...`, `! git ...`
|
|
11
|
+
KEYWORDS = %w[if then else elif do while until ! {].freeze
|
|
12
|
+
REDIRECTION = /\A\d*(?:[<>]{1,2}|&>)/
|
|
13
|
+
# A redirection whose target is the next word (`>`, `2>>`), not part of it (`>out`)
|
|
14
|
+
BARE_REDIRECTION = /#{REDIRECTION}\z/
|
|
15
|
+
ASSIGNMENT = /\A\w+=/
|
|
16
|
+
|
|
17
|
+
# words: the command and its arguments; env: the assignments before it, as a Hash;
|
|
18
|
+
# chdir: the directory a wrapper switches to (`sudo -D DIR`, `env -C DIR`), or nil
|
|
19
|
+
attr_reader :words, :env, :chdir
|
|
20
|
+
|
|
21
|
+
def initialize(segment)
|
|
22
|
+
@env = {}
|
|
23
|
+
@chdir = nil
|
|
24
|
+
@words = strip(without_redirections(split(segment).map { |word| unwrap(word) }))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def strip(words)
|
|
30
|
+
loop do
|
|
31
|
+
words = strip_assignments(words.drop_while { |word| KEYWORDS.include?(word) })
|
|
32
|
+
stripped, dir = Wrappers.strip(words)
|
|
33
|
+
return words if stripped == words
|
|
34
|
+
|
|
35
|
+
words = stripped
|
|
36
|
+
@chdir = dir || @chdir
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def strip_assignments(words)
|
|
41
|
+
while (word = words.first)&.match?(ASSIGNMENT)
|
|
42
|
+
name, value = word.split('=', 2)
|
|
43
|
+
@env[name] = value
|
|
44
|
+
words = words.drop(1)
|
|
45
|
+
end
|
|
46
|
+
words
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Drops `> file`, `2>&1`, `< list` and the like, which are not arguments
|
|
50
|
+
def without_redirections(words)
|
|
51
|
+
skip = false
|
|
52
|
+
words.reject do |word|
|
|
53
|
+
next (skip = false) || true if skip
|
|
54
|
+
|
|
55
|
+
skip = word.match?(BARE_REDIRECTION)
|
|
56
|
+
word.match?(REDIRECTION)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def split(segment)
|
|
61
|
+
Shellwords.split(segment)
|
|
62
|
+
rescue ArgumentError
|
|
63
|
+
segment.split
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Subshell brackets stuck to a word: `(cd x` and `y)`. Braces are words of their own in
|
|
67
|
+
# the shell (`{ git ...; }`), and `{}` is a placeholder for find and xargs
|
|
68
|
+
def unwrap(word)
|
|
69
|
+
word.delete_prefix('(').delete_suffix(')')
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Gitflash
|
|
6
|
+
module Hook
|
|
7
|
+
# What the PreToolUse hook outputs when it fails: in ask and deny mode the command is asked
|
|
8
|
+
# about or blocked, because letting it run would turn the user's protection off; in snapshot
|
|
9
|
+
# mode it runs, as it would without the hook. PostToolUse runs after the command, so there is
|
|
10
|
+
# nothing left to decide.
|
|
11
|
+
module Failure
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# The hook output as a Hash, or nil to leave the decision to Claude Code
|
|
15
|
+
def output(mode, error, input)
|
|
16
|
+
return nil if mode == 'snapshot' || post_event?(input)
|
|
17
|
+
|
|
18
|
+
{ hookSpecificOutput: { hookEventName: Claude::EVENT, permissionDecision: mode,
|
|
19
|
+
permissionDecisionReason: Messages.failure(error) } }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def post_event?(input)
|
|
23
|
+
JSON.parse(input)['hook_event_name'] == Claude::POST_EVENT
|
|
24
|
+
rescue StandardError
|
|
25
|
+
false
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'shellwords'
|
|
4
|
+
|
|
5
|
+
module Gitflash
|
|
6
|
+
module Hook
|
|
7
|
+
# Reads `git [global options] SUBCOMMAND ARGS` the way git does: `-C`, `--git-dir`,
|
|
8
|
+
# `--work-tree` and `GIT_DIR` / `GIT_WORK_TREE` decide the repository, and an alias (from
|
|
9
|
+
# `-c alias.NAME=...` or the repository's config) is replaced by what it stands for.
|
|
10
|
+
class GitInvocation
|
|
11
|
+
# dir: the work tree git uses; script: the shell command of a `!` alias, else nil
|
|
12
|
+
Parsed = Data.define(:dir, :subcommand, :args, :script)
|
|
13
|
+
|
|
14
|
+
VALUE_OPTIONS = %w[-C -c --git-dir --work-tree --namespace --exec-path --config-env
|
|
15
|
+
--super-prefix].freeze
|
|
16
|
+
MAX_ALIAS_DEPTH = 10
|
|
17
|
+
DIR_OPTIONS = { '-C' => :dir, '--git-dir' => :git_dir, '--work-tree' => :work_tree }.freeze
|
|
18
|
+
|
|
19
|
+
# Aliases from `git config` in the directory, as { name => value }; none when it is not a
|
|
20
|
+
# repository
|
|
21
|
+
def self.configured_aliases(dir)
|
|
22
|
+
stdout, _stderr, success = Git::BashCommand.capture('git', '-C', dir, 'config', '-z',
|
|
23
|
+
'--get-regexp', '^alias\\.')
|
|
24
|
+
return {} unless success
|
|
25
|
+
|
|
26
|
+
stdout.split("\0").to_h do |entry|
|
|
27
|
+
key, value = entry.split("\n", 2)
|
|
28
|
+
[key.delete_prefix('alias.'), value.to_s]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# `dir_after` resolves a path against a directory (CommandParser's rules for paths the
|
|
33
|
+
# shell decides); `aliases` returns the configured aliases of a directory
|
|
34
|
+
def initialize(dir_after:, aliases:)
|
|
35
|
+
@dir_after = dir_after
|
|
36
|
+
@aliases = aliases
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def parse(words, dir, env = {})
|
|
40
|
+
index, options = read_options(words, { dir: dir, aliases: {} })
|
|
41
|
+
dir = repository_dir(options, env)
|
|
42
|
+
subcommand = words[index]
|
|
43
|
+
args = words[(index + 1)..] || []
|
|
44
|
+
resolve(Parsed.new(dir:, subcommand:, args:, script: nil), options[:aliases])
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# Index of the subcommand, and what the global options say
|
|
50
|
+
def read_options(words, options)
|
|
51
|
+
index = 1
|
|
52
|
+
while (option = words[index])&.start_with?('-')
|
|
53
|
+
name, value = option.split('=', 2)
|
|
54
|
+
takes_next = value.nil? && VALUE_OPTIONS.include?(name)
|
|
55
|
+
apply(options, name, takes_next ? words[index + 1].to_s : value.to_s)
|
|
56
|
+
index += takes_next ? 2 : 1
|
|
57
|
+
end
|
|
58
|
+
[index, options]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def apply(options, name, value)
|
|
62
|
+
if (key = DIR_OPTIONS[name])
|
|
63
|
+
options[key] = @dir_after.call(value, options[:dir])
|
|
64
|
+
elsif name == '-c' && value.start_with?('alias.')
|
|
65
|
+
alias_name, expansion = value.delete_prefix('alias.').split('=', 2)
|
|
66
|
+
options[:aliases][alias_name] = expansion.to_s
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The work tree git uses: --work-tree or GIT_WORK_TREE, else the one --git-dir or GIT_DIR
|
|
71
|
+
# belongs to, else the directory after -C
|
|
72
|
+
def repository_dir(options, env)
|
|
73
|
+
work_tree = options[:work_tree] || from_env(env, 'GIT_WORK_TREE', options)
|
|
74
|
+
return work_tree if work_tree
|
|
75
|
+
|
|
76
|
+
git_dir = options[:git_dir] || from_env(env, 'GIT_DIR', options)
|
|
77
|
+
return options[:dir] unless git_dir
|
|
78
|
+
|
|
79
|
+
File.basename(git_dir) == '.git' ? File.dirname(git_dir) : git_dir
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def from_env(env, name, options)
|
|
83
|
+
env[name] && @dir_after.call(env[name], options[:dir])
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Git never lets an alias replace a built-in command
|
|
87
|
+
def alias_for(subcommand, inline, dir)
|
|
88
|
+
return nil if subcommand.nil? || CommandParser::BUILTINS.include?(subcommand)
|
|
89
|
+
|
|
90
|
+
inline[subcommand] || @aliases.call(dir)[subcommand]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Follows an alias to what it stands for, and an alias of an alias, as git does (bounded,
|
|
94
|
+
# so an alias loop cannot hang the hook)
|
|
95
|
+
def resolve(parsed, inline)
|
|
96
|
+
MAX_ALIAS_DEPTH.times do
|
|
97
|
+
expansion = parsed.script ? nil : alias_for(parsed.subcommand, inline, parsed.dir)
|
|
98
|
+
return parsed unless expansion
|
|
99
|
+
|
|
100
|
+
parsed = expand(expansion, parsed.args, parsed.dir)
|
|
101
|
+
end
|
|
102
|
+
parsed
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# `!cmd` aliases run in the shell; others are git arguments
|
|
106
|
+
def expand(expansion, args, dir)
|
|
107
|
+
if expansion.start_with?('!')
|
|
108
|
+
script = [expansion.delete_prefix('!'), *args].join(' ')
|
|
109
|
+
return Parsed.new(dir:, subcommand: nil, args: [], script:)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
words = Shellwords.split(expansion) + args
|
|
113
|
+
Parsed.new(dir:, subcommand: words.first, args: words.drop(1), script: nil)
|
|
114
|
+
rescue ArgumentError
|
|
115
|
+
Parsed.new(dir:, subcommand: nil, args: [], script: nil)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitflash
|
|
4
|
+
module Hook
|
|
5
|
+
# Rules for the less common git commands that lose work: plumbing that overwrites files,
|
|
6
|
+
# commands that delete the reflog or unreachable objects `gitflash undo` and git rely on,
|
|
7
|
+
# and branch deletion through `git push .`. Same contract as Rules.
|
|
8
|
+
module HistoryRules
|
|
9
|
+
FULL = %w[branches head worktree].freeze
|
|
10
|
+
WORKTREE = { scope: %w[worktree] }.freeze
|
|
11
|
+
PRUNE_NOW = /\A--prune=(?:now|all)\z/
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# `git rm -f` drops uncommitted changes in the files it removes
|
|
16
|
+
def rm(args, _dir)
|
|
17
|
+
forced = args.include?('--force') || Rules.short_flags(args).include?('f')
|
|
18
|
+
forced && !args.include?('--cached') ? WORKTREE : nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# `git checkout-index -f` overwrites files in the working tree
|
|
22
|
+
def checkout_index(args, _dir)
|
|
23
|
+
args.include?('--force') || Rules.short_flags(args).include?('f') ? WORKTREE : nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# `git read-tree -u --reset` overwrites the working tree with the tree it reads
|
|
27
|
+
def read_tree(args, _dir)
|
|
28
|
+
args.include?('-u') && args.include?('--reset') ? WORKTREE : nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Expiring or deleting reflog entries drops the record of where branches were; the
|
|
32
|
+
# snapshot keeps their current tips reachable
|
|
33
|
+
def reflog(args, _dir)
|
|
34
|
+
%w[expire delete].include?(args.first) ? { scope: FULL } : nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Pruning unreachable objects now removes commits only the reflog still pointed at
|
|
38
|
+
def gc(args, _dir)
|
|
39
|
+
args.any? { |arg| arg.match?(PRUNE_NOW) } ? { scope: FULL } : nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def prune(_args, _dir)
|
|
43
|
+
{ scope: FULL }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# `git push . :branch`, `git push . --delete branch`: deletes a branch of this repository
|
|
47
|
+
def push(args, _dir)
|
|
48
|
+
positional = args.reject { |arg| arg.start_with?('-') }
|
|
49
|
+
return nil unless positional.first == '.'
|
|
50
|
+
|
|
51
|
+
names = deleted_refs(args, positional.drop(1))
|
|
52
|
+
return nil if names.nil?
|
|
53
|
+
|
|
54
|
+
# Names the shell or xargs fills in are unknown: save every branch
|
|
55
|
+
names = [] if names.any? { |name| Paths.unresolved?(name) || name.include?('{}') }
|
|
56
|
+
{ scope: %w[branches], branches: names }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Branch names a local push deletes: every ref with --delete, else the `:ref` ones.
|
|
60
|
+
# [] when --delete names no refs (they come from stdin, as with xargs): every branch is
|
|
61
|
+
# saved; nil when the push deletes nothing
|
|
62
|
+
def deleted_refs(args, refs)
|
|
63
|
+
delete = args.include?('--delete') || Rules.short_flags(args).include?('d')
|
|
64
|
+
refs = refs.select { |ref| ref.start_with?(':') } unless delete
|
|
65
|
+
return nil if refs.empty? && !delete
|
|
66
|
+
|
|
67
|
+
refs.map { |ref| ref.delete_prefix(':').delete_prefix(Repo::HEADS) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
RULES = {
|
|
71
|
+
'rm' => method(:rm),
|
|
72
|
+
'checkout-index' => method(:checkout_index),
|
|
73
|
+
'read-tree' => method(:read_tree),
|
|
74
|
+
'reflog' => method(:reflog),
|
|
75
|
+
'gc' => method(:gc),
|
|
76
|
+
'prune' => method(:prune),
|
|
77
|
+
'push' => method(:push)
|
|
78
|
+
}.freeze
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitflash
|
|
4
|
+
module Hook
|
|
5
|
+
# What a snapshot cannot bring back: ignored files a command deletes (`git clean -x`,
|
|
6
|
+
# `git worktree remove`) and untracked files too large to save. The hook names them so the
|
|
7
|
+
# agent is not told everything can be undone.
|
|
8
|
+
module Losses
|
|
9
|
+
SHOWN = 5
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# A note for Claude about the targets and snapshots, or nil when nothing is lost for good
|
|
14
|
+
def note(saved, targets)
|
|
15
|
+
notes = targets.select(&:ignored).filter_map { |target| ignored_note(target) }
|
|
16
|
+
notes += saved.filter_map { |_target, snapshot| skipped_note(snapshot) }
|
|
17
|
+
notes.empty? ? nil : notes.join(' ')
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def ignored_note(target)
|
|
21
|
+
files = ignored_files(target.dir)
|
|
22
|
+
return nil if files.empty?
|
|
23
|
+
|
|
24
|
+
"`#{target.command}` also deletes ignored files in #{target.dir}, which no snapshot " \
|
|
25
|
+
"saves: #{listing(files)}."
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def skipped_note(snapshot)
|
|
29
|
+
return nil if snapshot.skipped_files.empty?
|
|
30
|
+
|
|
31
|
+
"Snapshot #{snapshot.id} left out untracked files over 50 MB, which cannot be restored: " \
|
|
32
|
+
"#{listing(snapshot.skipped_files)}."
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Ignored files and directories git reports in the directory (a whole ignored directory is
|
|
36
|
+
# one entry)
|
|
37
|
+
def ignored_files(dir)
|
|
38
|
+
stdout, _stderr, success = Git::BashCommand.capture(
|
|
39
|
+
'git', '-C', dir, 'ls-files', '-z', '--others', '--ignored', '--exclude-standard',
|
|
40
|
+
'--directory'
|
|
41
|
+
)
|
|
42
|
+
success ? stdout.split("\0") : []
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def listing(files)
|
|
46
|
+
shown = files.first(SHOWN).map { |file| TerminalText.line(file) }.join(', ')
|
|
47
|
+
files.size > SHOWN ? "#{shown} and #{files.size - SHOWN} more" : shown
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -6,6 +6,15 @@ module Gitflash
|
|
|
6
6
|
module Messages
|
|
7
7
|
module_function
|
|
8
8
|
|
|
9
|
+
# Everything the hook tells Claude about the commands it saved ([target, snapshot] pairs)
|
|
10
|
+
# and the ones it could not save
|
|
11
|
+
def context(saved, unsaved)
|
|
12
|
+
targets = saved.map(&:first) + unsaved
|
|
13
|
+
guessed = targets.select(&:guessed)
|
|
14
|
+
[(saved(saved) unless saved.empty?), (unsaved(unsaved) unless unsaved.empty?),
|
|
15
|
+
(guessed(guessed) unless guessed.empty?), Losses.note(saved, targets)].compact.join(' ')
|
|
16
|
+
end
|
|
17
|
+
|
|
9
18
|
def saved(saved)
|
|
10
19
|
notes = saved.map { |target, snap| "snapshot #{snap.id} before `#{target.command}`" }
|
|
11
20
|
"gitflash saved #{notes.join('; ')}. If this discards work that was still needed, " \
|
|
@@ -20,8 +29,20 @@ module Gitflash
|
|
|
20
29
|
end
|
|
21
30
|
|
|
22
31
|
def unsaved(targets)
|
|
23
|
-
|
|
24
|
-
|
|
32
|
+
dirs = targets.map(&:dir).uniq.join(', ')
|
|
33
|
+
"gitflash could not save a snapshot before #{commands(targets)} in #{dirs} (not a git " \
|
|
34
|
+
'work tree, does not exist, or the snapshot failed), so nothing it discards there can ' \
|
|
35
|
+
'be restored with `gitflash undo`. Run `gitflash snapshot` in that repository first ' \
|
|
36
|
+
'if the work matters.'
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The directory of these commands is decided by the shell (`cd "$REPO"`, `git -C "$X"`),
|
|
40
|
+
# so the snapshot was taken where Claude runs and may not cover what they change
|
|
41
|
+
def guessed(targets)
|
|
42
|
+
"The shell decides where #{commands(targets)} runs, so gitflash could only guess the " \
|
|
43
|
+
"directory (#{targets.map(&:dir).uniq.join(', ')}). If the command changes another " \
|
|
44
|
+
'repository, nothing it discards there can be restored; run `gitflash snapshot` in ' \
|
|
45
|
+
'that repository first if the work matters.'
|
|
25
46
|
end
|
|
26
47
|
|
|
27
48
|
def denial(targets)
|
|
@@ -30,6 +51,11 @@ module Gitflash
|
|
|
30
51
|
'saves a snapshot first, or run `gitflash snapshot` before retrying.'
|
|
31
52
|
end
|
|
32
53
|
|
|
54
|
+
def failure(error)
|
|
55
|
+
"gitflash could not check this command (#{error.message}), so it cannot tell whether " \
|
|
56
|
+
'it discards work git cannot restore. Run `gitflash snapshot` first if it might.'
|
|
57
|
+
end
|
|
58
|
+
|
|
33
59
|
def commands(targets)
|
|
34
60
|
targets.map { |target| "`#{target.command}`" }.join(', ')
|
|
35
61
|
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitflash
|
|
4
|
+
module Hook
|
|
5
|
+
# How the hook reads a path from a command line, without a shell to run it
|
|
6
|
+
module Paths
|
|
7
|
+
# A directory the hook guessed because the shell decides the real one when it runs
|
|
8
|
+
# (`cd "$REPO"`): the hook's cwd, or a path relative to it. A snapshot there may not cover
|
|
9
|
+
# the repository the command changes, and the hook says so.
|
|
10
|
+
class Guessed < String; end
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# The directory after `cd PATH` or `git -C PATH` from dir. A path the shell decides falls
|
|
15
|
+
# back to `fallback` (the directory Claude runs in) as a Guessed directory, and a relative
|
|
16
|
+
# path from a guessed directory stays guessed.
|
|
17
|
+
def change_dir(path, dir, fallback)
|
|
18
|
+
return Guessed.new(fallback) if unresolved?(path)
|
|
19
|
+
|
|
20
|
+
resolved = expand(path, dir)
|
|
21
|
+
dir.is_a?(Guessed) && !absolute?(path) ? Guessed.new(resolved) : resolved
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def guessed?(dir)
|
|
25
|
+
dir.is_a?(Guessed)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def absolute?(path)
|
|
29
|
+
path.start_with?('/')
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# File.expand_path, except that `~user` for an unknown user is kept as a literal name
|
|
33
|
+
# instead of raising: an error here must not stop the hook from deciding
|
|
34
|
+
def expand(path, dir)
|
|
35
|
+
File.expand_path(path, dir)
|
|
36
|
+
rescue ArgumentError
|
|
37
|
+
File.join(File.expand_path(dir), path)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Whether the shell decides the path when it runs: a variable, a command substitution or
|
|
41
|
+
# `cd -`. The text alone does not say which directory that is.
|
|
42
|
+
def unresolved?(path)
|
|
43
|
+
path == '-' || path.match?(/[$`]/)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
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
|
-
removes = any?(flags, %w[--force --interactive]) || short.match?(/[fi]/)
|
|
50
|
+
removes = any?(flags, %w[--force --interactive]) || short.match?(/[fi]/) ||
|
|
51
|
+
!require_force?(dir)
|
|
45
52
|
dry_run = flags.include?('--dry-run') || short.include?('n')
|
|
46
|
-
removes && !dry_run
|
|
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)
|
|
@@ -63,10 +79,15 @@ module Gitflash
|
|
|
63
79
|
short_flags(args).match?(/[dDfmMcC]/)
|
|
64
80
|
return nil unless changes
|
|
65
81
|
|
|
66
|
-
|
|
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 }
|
|
67
86
|
end
|
|
68
87
|
|
|
69
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')
|
|
70
91
|
return nil unless args.include?('-d')
|
|
71
92
|
|
|
72
93
|
heads = args.select { |arg| arg.start_with?('refs/heads/') }
|
|
@@ -81,12 +102,13 @@ module Gitflash
|
|
|
81
102
|
any?(args, %w[--abort --quit]) ? { scope: %w[worktree] } : nil
|
|
82
103
|
end
|
|
83
104
|
|
|
84
|
-
# `git worktree remove
|
|
105
|
+
# `git worktree remove PATH` deletes that worktree's ignored files, and with --force its
|
|
106
|
+
# uncommitted ones too
|
|
85
107
|
def worktree(args, dir)
|
|
86
|
-
return nil unless args.first == 'remove'
|
|
108
|
+
return nil unless args.first == 'remove'
|
|
87
109
|
|
|
88
110
|
path = args[1..].reject { |arg| arg.start_with?('-') }.first
|
|
89
|
-
path && { scope: %w[worktree], dir:
|
|
111
|
+
path && { scope: %w[worktree], dir: Paths.change_dir(path, dir, dir), ignored: true }
|
|
90
112
|
end
|
|
91
113
|
|
|
92
114
|
RULES = {
|
|
@@ -104,7 +126,7 @@ module Gitflash
|
|
|
104
126
|
'revert' => method(:abort),
|
|
105
127
|
'am' => method(:abort),
|
|
106
128
|
'worktree' => method(:worktree)
|
|
107
|
-
}.freeze
|
|
129
|
+
}.merge(HistoryRules::RULES).freeze
|
|
108
130
|
end
|
|
109
131
|
end
|
|
110
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
|