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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +15 -11
  4. data/command_descriptions.yml +21 -7
  5. data/lib/gitflash/atomic_file.rb +40 -0
  6. data/lib/gitflash/cli.rb +27 -4
  7. data/lib/gitflash/command_runner.rb +12 -1
  8. data/lib/gitflash/commands/clean.rb +29 -1
  9. data/lib/gitflash/commands/gc.rb +19 -4
  10. data/lib/gitflash/commands/hook_install.rb +66 -12
  11. data/lib/gitflash/commands/mark.rb +9 -3
  12. data/lib/gitflash/commands/snapshot_create.rb +6 -4
  13. data/lib/gitflash/commands/undo.rb +18 -8
  14. data/lib/gitflash/commands/worktree_command.rb +17 -3
  15. data/lib/gitflash/commands/worktree_remove.rb +14 -5
  16. data/lib/gitflash/config.rb +63 -13
  17. data/lib/gitflash/config_keys.rb +52 -4
  18. data/lib/gitflash/confirmation_required.rb +6 -2
  19. data/lib/gitflash/hook/claude.rb +17 -12
  20. data/lib/gitflash/hook/command_parser.rb +91 -68
  21. data/lib/gitflash/hook/command_words.rb +73 -0
  22. data/lib/gitflash/hook/failure.rb +29 -0
  23. data/lib/gitflash/hook/git_invocation.rb +119 -0
  24. data/lib/gitflash/hook/history_rules.rb +81 -0
  25. data/lib/gitflash/hook/losses.rb +51 -0
  26. data/lib/gitflash/hook/messages.rb +28 -2
  27. data/lib/gitflash/hook/paths.rb +47 -0
  28. data/lib/gitflash/hook/rules.rb +31 -9
  29. data/lib/gitflash/hook/settings.rb +19 -2
  30. data/lib/gitflash/hook/shell_quotes.rb +64 -0
  31. data/lib/gitflash/hook/shell_text.rb +101 -0
  32. data/lib/gitflash/hook/wrappers.rb +67 -0
  33. data/lib/gitflash/hook_cli.rb +27 -4
  34. data/lib/gitflash/protection.rb +12 -4
  35. data/lib/gitflash/snapshot.rb +9 -2
  36. data/lib/gitflash/snapshot_check.rb +60 -0
  37. data/lib/gitflash/snapshots.rb +26 -3
  38. data/lib/gitflash/state_capture.rb +18 -21
  39. data/lib/gitflash/ui.rb +31 -3
  40. data/lib/gitflash/untracked_files.rb +19 -0
  41. data/lib/gitflash/usage_report.rb +66 -0
  42. data/lib/gitflash/version.rb +1 -1
  43. data/schema/v1.json +39 -2
  44. 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
- "gitflash could not save a snapshot before #{commands(targets)} (the directory is not a " \
24
- 'git work tree or does not exist), so nothing can be restored with `gitflash undo`.'
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
@@ -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?(File.expand_path(arg, dir))
42
+ File.exist?(Paths.expand(arg, dir))
38
43
  end
39
44
  end
40
45
 
41
- def clean(args, _dir)
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 ? { scope: %w[worktree] } : nil
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
- { scope: %w[branches], branches: args.reject { |arg| arg.start_with?('-') } }
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 --force PATH` deletes that worktree's uncommitted files
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' && any?(args, %w[-f --force])
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: File.expand_path(path, 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
- groups = Array(settings.dig('hooks', event))
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