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
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'shellwords'
|
|
4
|
-
|
|
5
3
|
module Gitflash
|
|
6
4
|
module Hook
|
|
7
5
|
# Finds git commands in a shell command line that can lose work git itself cannot restore:
|
|
@@ -13,14 +11,42 @@ module Gitflash
|
|
|
13
11
|
# `cd DIR` and `git -C DIR`, and may report a command more often than needed. An extra
|
|
14
12
|
# snapshot is harmless; a missed one is not.
|
|
15
13
|
class CommandParser
|
|
16
|
-
# dir: where to take the snapshot; branches: names, or :current for the checked-out one
|
|
17
|
-
|
|
14
|
+
# dir: where to take the snapshot; branches: names, or :current for the checked-out one;
|
|
15
|
+
# guessed: the shell decides the directory (`cd "$REPO"`), so dir is only a fallback;
|
|
16
|
+
# ignored: whether the command also deletes ignored files, which no snapshot saves
|
|
17
|
+
Target = Data.define(:dir, :command, :scope, :branches, :guessed, :ignored) do
|
|
18
|
+
# One target for all the commands of a group that run in dir
|
|
19
|
+
def self.merge(dir, group)
|
|
20
|
+
new(dir: dir, command: group.map(&:command).join(' && '),
|
|
21
|
+
scope: group.flat_map(&:scope).uniq, branches: merged_branches(group),
|
|
22
|
+
guessed: group.any?(&:guessed), ignored: group.any?(&:ignored))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# A target that saves branches without naming them saves every branch, so the merge
|
|
26
|
+
# does too: naming the others' branches would drop the ones it could not name
|
|
27
|
+
def self.merged_branches(group)
|
|
28
|
+
all = group.any? { |target| target.scope.include?('branches') && target.branches.empty? }
|
|
29
|
+
all ? [] : group.flat_map(&:branches).uniq
|
|
30
|
+
end
|
|
31
|
+
end
|
|
18
32
|
|
|
19
|
-
|
|
20
|
-
|
|
33
|
+
SHELLS = %w[bash sh zsh dash ksh].freeze
|
|
34
|
+
MAX_NESTING = 3
|
|
35
|
+
# Subcommands git never lets an alias replace and that have no rule, so their aliases need
|
|
36
|
+
# not be read
|
|
37
|
+
BUILTINS = (Rules::RULES.keys + %w[status log diff show add commit push pull fetch rev-parse
|
|
38
|
+
config ls-files remote describe blame grep tag init
|
|
39
|
+
clone mv]).freeze
|
|
21
40
|
|
|
22
|
-
|
|
41
|
+
DIR_COMMANDS = %w[cd pushd popd].freeze
|
|
42
|
+
|
|
43
|
+
# `aliases` returns the git aliases configured for a directory, as { name => value }
|
|
44
|
+
def initialize(cwd, aliases: GitInvocation.method(:configured_aliases))
|
|
23
45
|
@cwd = cwd
|
|
46
|
+
@aliases = aliases
|
|
47
|
+
@alias_cache = {}
|
|
48
|
+
@invocation = GitInvocation.new(dir_after: method(:change_dir),
|
|
49
|
+
aliases: method(:aliases_in))
|
|
24
50
|
end
|
|
25
51
|
|
|
26
52
|
# Git commands that can lose work, with what to save before them
|
|
@@ -29,8 +55,10 @@ module Gitflash
|
|
|
29
55
|
rule = Rules.for(git.subcommand, git.args, git.dir)
|
|
30
56
|
next unless rule
|
|
31
57
|
|
|
32
|
-
|
|
33
|
-
|
|
58
|
+
dir = rule.fetch(:dir, git.dir)
|
|
59
|
+
Target.new(dir: dir.to_s, command: git.command, scope: rule[:scope],
|
|
60
|
+
branches: rule[:branches] || [], guessed: Paths.guessed?(dir),
|
|
61
|
+
ignored: rule.fetch(:ignored, false))
|
|
34
62
|
end
|
|
35
63
|
end
|
|
36
64
|
|
|
@@ -48,73 +76,86 @@ module Gitflash
|
|
|
48
76
|
# subcommand and its arguments
|
|
49
77
|
GitCommand = Data.define(:dir, :command, :subcommand, :args)
|
|
50
78
|
|
|
51
|
-
def git_commands(command_line)
|
|
52
|
-
|
|
53
|
-
segments(command_line).
|
|
54
|
-
words =
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
dir = File.expand_path(words[1] || Dir.home, dir)
|
|
59
|
-
next
|
|
79
|
+
def git_commands(command_line, dir = @cwd, depth = 0)
|
|
80
|
+
stack = []
|
|
81
|
+
ShellText.segments(command_line).flat_map do |segment|
|
|
82
|
+
words = CommandWords.new(segment)
|
|
83
|
+
if DIR_COMMANDS.include?(words.words.first)
|
|
84
|
+
dir = dir_after(words.words, dir, stack)
|
|
85
|
+
next substituted(segment, dir, depth)
|
|
60
86
|
end
|
|
61
|
-
|
|
87
|
+
substituted(segment, dir, depth) + in_segment(words, dir, segment.strip, depth)
|
|
62
88
|
end
|
|
63
89
|
end
|
|
64
90
|
|
|
65
|
-
#
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
91
|
+
# `cd DIR` and `pushd DIR` move, `pushd` remembering where it was; `popd` goes back there,
|
|
92
|
+
# or to the directory Claude runs in when the line pushed nothing
|
|
93
|
+
def dir_after(words, dir, stack)
|
|
94
|
+
return stack.pop || @cwd if words.first == 'popd'
|
|
95
|
+
|
|
96
|
+
stack.push(dir) if words.first == 'pushd'
|
|
97
|
+
change_dir(words[1] || Dir.home, dir)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def in_segment(words, dir, segment, depth)
|
|
101
|
+
return [] if words.words.empty?
|
|
102
|
+
|
|
103
|
+
dir = change_dir(words.chdir, dir) if words.chdir
|
|
104
|
+
commands_in(words.words, dir, segment, depth, words.env)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Git commands inside `$(...)`, `<(...)` and backticks, which the shell runs first
|
|
108
|
+
def substituted(command_line, dir, depth)
|
|
109
|
+
ShellText.substitutions(command_line).flat_map do |script|
|
|
110
|
+
nested(script, dir, depth)
|
|
79
111
|
end
|
|
80
|
-
parts.reject { |part| part.strip.empty? }
|
|
81
112
|
end
|
|
82
113
|
|
|
83
|
-
#
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
114
|
+
# The git commands of a script inside this one. Past the nesting limit a script that
|
|
115
|
+
# mentions git counts as risky without being read, so deep nesting cannot hide a command.
|
|
116
|
+
def nested(script, dir, depth)
|
|
117
|
+
return git_commands(script, dir, depth + 1) if depth < MAX_NESTING
|
|
118
|
+
return [] unless script.match?(/\bgit\b/)
|
|
119
|
+
|
|
120
|
+
[GitCommand.new(dir: dir, command: script.strip, subcommand: Rules::UNREAD, args: [])]
|
|
88
121
|
end
|
|
89
122
|
|
|
90
|
-
|
|
91
|
-
|
|
123
|
+
# The git command of one segment, or the ones inside `bash -c "..."`, `eval "..."`,
|
|
124
|
+
# `find -exec` and `!` aliases
|
|
125
|
+
def commands_in(words, dir, segment, depth, env = {})
|
|
126
|
+
words = Wrappers.find_exec(words) || words
|
|
127
|
+
git = File.basename(words.first.to_s) == 'git'
|
|
128
|
+
return git_command(words, dir, segment, depth, env) if git
|
|
129
|
+
|
|
130
|
+
script = nested_script(words)
|
|
131
|
+
script ? nested(script, dir, depth) : []
|
|
92
132
|
end
|
|
93
133
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
134
|
+
# The script text passed to a shell (`bash -c`, `sh -lc`) or to `eval`
|
|
135
|
+
def nested_script(words)
|
|
136
|
+
return words.drop(1).join(' ') if words.first == 'eval'
|
|
137
|
+
return nil unless SHELLS.include?(File.basename(words.first))
|
|
138
|
+
|
|
139
|
+
index = words.index { |word| word.match?(/\A-[a-z]*c[a-z]*\z/) }
|
|
140
|
+
index && words[index + 1]
|
|
98
141
|
end
|
|
99
142
|
|
|
100
|
-
|
|
101
|
-
|
|
143
|
+
# The directory after `cd PATH` or `git -C PATH`. A path the shell decides when it runs
|
|
144
|
+
# (`cd "$(git rev-parse --show-toplevel)"`, `cd "$REPO"`, `cd -`) falls back to the
|
|
145
|
+
# directory Claude runs in: a snapshot there is better than none.
|
|
146
|
+
def change_dir(path, dir)
|
|
147
|
+
Paths.change_dir(path, dir, @cwd)
|
|
102
148
|
end
|
|
103
149
|
|
|
104
|
-
def git_command(words, dir, command)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
150
|
+
def git_command(words, dir, command, depth, env)
|
|
151
|
+
git = @invocation.parse(words, dir, env)
|
|
152
|
+
return nested(git.script, git.dir, depth) if git.script
|
|
153
|
+
|
|
154
|
+
[GitCommand.new(dir: git.dir, command: command, subcommand: git.subcommand, args: git.args)]
|
|
108
155
|
end
|
|
109
156
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
index = 1
|
|
113
|
-
while (option = words[index])&.start_with?('-')
|
|
114
|
-
dir = File.expand_path(words[index + 1].to_s, dir) if option == '-C'
|
|
115
|
-
index += option == '-C' || OPTIONS_WITH_VALUE.include?(option) ? 2 : 1
|
|
116
|
-
end
|
|
117
|
-
[index, dir]
|
|
157
|
+
def aliases_in(dir)
|
|
158
|
+
@alias_cache[dir] ||= @aliases.call(dir)
|
|
118
159
|
end
|
|
119
160
|
end
|
|
120
161
|
end
|
|
@@ -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
|
|
@@ -0,0 +1,64 @@
|
|
|
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
|
+
# 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
|
+
|
|
18
|
+
def saved(saved)
|
|
19
|
+
notes = saved.map { |target, snap| "snapshot #{snap.id} before `#{target.command}`" }
|
|
20
|
+
"gitflash saved #{notes.join('; ')}. If this discards work that was still needed, " \
|
|
21
|
+
"restore it with #{undo_commands(saved)} (list snapshots with `gitflash snapshots`)."
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# One command per snapshot: with several directories each snapshot belongs to its own
|
|
25
|
+
def undo_commands(saved)
|
|
26
|
+
return "`gitflash undo #{saved.first[1].id}`" if saved.size == 1
|
|
27
|
+
|
|
28
|
+
saved.map { |target, snap| "`gitflash undo #{snap.id}` (run in #{target.dir})" }.join(', ')
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def unsaved(targets)
|
|
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.'
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def denial(targets)
|
|
49
|
+
"gitflash blocked #{commands(targets)}: it can discard work git cannot restore. " \
|
|
50
|
+
'Use the gitflash equivalent (gitflash reset, gitflash delete, ...), which ' \
|
|
51
|
+
'saves a snapshot first, or run `gitflash snapshot` before retrying.'
|
|
52
|
+
end
|
|
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
|
+
|
|
59
|
+
def commands(targets)
|
|
60
|
+
targets.map { |target| "`#{target.command}`" }.join(', ')
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
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
|