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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -0
  3. data/README.md +17 -13
  4. data/command_descriptions.yml +25 -7
  5. data/lib/gitflash/atomic_file.rb +40 -0
  6. data/lib/gitflash/cherry_pick/log_parser.rb +63 -0
  7. data/lib/gitflash/cherry_pick.rb +10 -18
  8. data/lib/gitflash/cli.rb +28 -5
  9. data/lib/gitflash/command_runner.rb +12 -1
  10. data/lib/gitflash/commands/base.rb +3 -0
  11. data/lib/gitflash/commands/clean.rb +29 -1
  12. data/lib/gitflash/commands/gc.rb +19 -4
  13. data/lib/gitflash/commands/hook_install.rb +79 -12
  14. data/lib/gitflash/commands/mark.rb +9 -3
  15. data/lib/gitflash/commands/pick.rb +13 -1
  16. data/lib/gitflash/commands/pick_control.rb +1 -1
  17. data/lib/gitflash/commands/removal_report.rb +8 -3
  18. data/lib/gitflash/commands/snapshot_create.rb +6 -4
  19. data/lib/gitflash/commands/undo.rb +24 -11
  20. data/lib/gitflash/commands/worktree_add.rb +6 -4
  21. data/lib/gitflash/commands/worktree_command.rb +17 -3
  22. data/lib/gitflash/commands/worktree_list.rb +3 -3
  23. data/lib/gitflash/commands/worktree_lock.rb +7 -3
  24. data/lib/gitflash/commands/worktree_move.rb +9 -4
  25. data/lib/gitflash/commands/worktree_prune.rb +1 -1
  26. data/lib/gitflash/commands/worktree_remove.rb +23 -7
  27. data/lib/gitflash/config.rb +69 -16
  28. data/lib/gitflash/config_keys.rb +64 -4
  29. data/lib/gitflash/confirmation_required.rb +6 -2
  30. data/lib/gitflash/git/bash_command.rb +5 -2
  31. data/lib/gitflash/hook/branch_marker.rb +109 -6
  32. data/lib/gitflash/hook/claude.rb +45 -24
  33. data/lib/gitflash/hook/command_parser.rb +100 -59
  34. data/lib/gitflash/hook/command_words.rb +73 -0
  35. data/lib/gitflash/hook/failure.rb +29 -0
  36. data/lib/gitflash/hook/git_invocation.rb +119 -0
  37. data/lib/gitflash/hook/history_rules.rb +81 -0
  38. data/lib/gitflash/hook/losses.rb +51 -0
  39. data/lib/gitflash/hook/messages.rb +64 -0
  40. data/lib/gitflash/hook/paths.rb +47 -0
  41. data/lib/gitflash/hook/rules.rb +36 -12
  42. data/lib/gitflash/hook/settings.rb +19 -2
  43. data/lib/gitflash/hook/shell_quotes.rb +64 -0
  44. data/lib/gitflash/hook/shell_text.rb +101 -0
  45. data/lib/gitflash/hook/wrappers.rb +67 -0
  46. data/lib/gitflash/hook_cli.rb +34 -4
  47. data/lib/gitflash/protection.rb +12 -4
  48. data/lib/gitflash/repo.rb +3 -2
  49. data/lib/gitflash/snapshot.rb +9 -2
  50. data/lib/gitflash/snapshot_check.rb +60 -0
  51. data/lib/gitflash/snapshots.rb +26 -3
  52. data/lib/gitflash/state_capture.rb +26 -24
  53. data/lib/gitflash/terminal_text.rb +9 -0
  54. data/lib/gitflash/ui.rb +32 -4
  55. data/lib/gitflash/untracked_files.rb +19 -0
  56. data/lib/gitflash/usage_report.rb +66 -0
  57. data/lib/gitflash/version.rb +1 -1
  58. data/lib/gitflash/worktree.rb +4 -4
  59. data/schema/v1.json +39 -2
  60. 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
- Target = Data.define(:dir, :command, :scope, :branches)
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
- PREFIXES = %w[sudo command exec time nohup env].freeze
20
- OPTIONS_WITH_VALUE = %w[-c --git-dir --work-tree --namespace --exec-path --config-env].freeze
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
- def initialize(cwd)
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
- Target.new(dir: rule.fetch(:dir, git.dir), command: git.command, scope: rule[:scope],
33
- branches: rule[:branches] || [])
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
- dir = @cwd
53
- segments(command_line).filter_map do |segment|
54
- words = words(segment)
55
- next if words.empty?
56
-
57
- if words.first == 'cd'
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
- git_command(words, dir, segment.strip) if File.basename(words.first) == 'git'
87
+ substituted(segment, dir, depth) + in_segment(words, dir, segment.strip, depth)
62
88
  end
63
89
  end
64
90
 
65
- # Splits on unquoted ; & | and newlines
66
- def segments(line)
67
- parts = [+'']
68
- quote = nil
69
- line.each_char do |char|
70
- if quote
71
- quote = nil if char == quote
72
- elsif ["'", '"'].include?(char)
73
- quote = char
74
- elsif ";&|\n".include?(char)
75
- parts << +''
76
- next
77
- end
78
- parts.last << char
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
- # Shell words without subshell brackets, environment assignments and wrappers like sudo
84
- def words(segment)
85
- words = split(segment).map { |word| unwrap(word) }.reject(&:empty?)
86
- words.shift while words.first && prefix?(words.first)
87
- words
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
- def prefix?(word)
91
- PREFIXES.include?(word) || word.match?(/\A\w+=/)
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
- def split(segment)
95
- Shellwords.split(segment)
96
- rescue ArgumentError
97
- segment.split
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
- def unwrap(word)
101
- word.delete_prefix('(').delete_prefix('{').delete_suffix(')').delete_suffix('}')
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
- index, dir = skip_git_options(words, dir)
106
- GitCommand.new(dir: dir, command: command, subcommand: words[index],
107
- args: words[(index + 1)..] || [])
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
- # Index of the subcommand, and the directory after any -C options
111
- def skip_git_options(words, dir)
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