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
@@ -4,29 +4,52 @@ require 'yaml'
4
4
 
5
5
  module Gitflash
6
6
  # Settings from `.gitflash.yml` in the main checkout and `~/.config/gitflash.yml`.
7
- # Precedence: repository file, then user file, then built-in defaults.
7
+ # Precedence: repository file, then user file, then built-in defaults; for `worktree_dir` the
8
+ # user file wins, since it decides where on the user's disk checkouts are written.
8
9
  # Command-line flags are applied by the commands themselves and win over all of these.
9
10
  # An unknown key or a value of the wrong type raises a UsageError, so a typo never goes unnoticed.
10
- Config = Data.define(:protected_patterns, :stale_days, :worktree_dir) do
11
- # `root` is the main checkout; `home` is where the user file lives
11
+ Config = Data.define(:protected_patterns, :stale_days, :worktree_dir, :worktree_dir_from_repo,
12
+ :snapshot_exclude) do
13
+ # `root` is the main checkout; `home` is where the user file lives. The repository file wins
14
+ # key by key, except `protected`: both files add patterns, so a repository can protect more
15
+ # branches but never remove what the user protects; and `worktree_dir`, where the user file
16
+ # wins. A cloned repository's file may only place worktrees next to the main checkout.
12
17
  def self.load(root:, home: Dir.home)
13
- values = [File.join(home, ConfigKeys::USER_FILE), File.join(root, ConfigKeys::REPO_FILE)]
14
- .map { |path| read(path) }
15
- .reduce(ConfigKeys::DEFAULTS) { |merged, layer| merged.merge(layer) }
16
- new(protected_patterns: values['protected'], stale_days: values['stale_days'],
17
- worktree_dir: values['worktree_dir'])
18
+ user = read(File.join(home, ConfigKeys::USER_FILE))
19
+ repo = read(File.join(root, ConfigKeys::REPO_FILE), repository: true)
20
+ values = ConfigKeys::DEFAULTS.merge(user).merge(repo)
21
+ new(protected_patterns: both([user, repo], 'protected'), stale_days: values['stale_days'],
22
+ snapshot_exclude: both([user, repo], 'snapshot_exclude'), **worktree_dir_of(user, repo))
18
23
  end
19
24
 
25
+ # Lists that both files add to
26
+ def self.both(layers, key)
27
+ layers.flat_map { |layer| layer.fetch(key, []) }.uniq
28
+ end
29
+ private_class_method :both
30
+
31
+ # The user file wins for worktree_dir; a value from the repository is checked again where
32
+ # it leads (see ConfigKeys.check_contained!)
33
+ def self.worktree_dir_of(user, repo)
34
+ from_repo = !user.key?('worktree_dir') && repo.key?('worktree_dir')
35
+ dir = from_repo ? repo['worktree_dir'] : user.fetch('worktree_dir', ConfigKeys::DEFAULT_WORKTREE_DIR)
36
+ { worktree_dir: dir,
37
+ worktree_dir_from_repo: from_repo }
38
+ end
39
+ private_class_method :worktree_dir_of
40
+
20
41
  def self.defaults
21
- new(protected_patterns: [], stale_days: 30, worktree_dir: ConfigKeys::DEFAULT_WORKTREE_DIR)
42
+ new(protected_patterns: [], stale_days: 30, worktree_dir: ConfigKeys::DEFAULT_WORKTREE_DIR,
43
+ worktree_dir_from_repo: false, snapshot_exclude: [])
22
44
  end
23
45
 
24
- def self.read(path)
46
+ def self.read(path, repository: false)
25
47
  return {} unless File.file?(path)
26
48
 
27
49
  reject_duplicate_keys!(path)
28
50
  data = YAML.safe_load_file(path) || {}
29
51
  validate!(data, path)
52
+ check_repository_dir!(data['worktree_dir'], path) if repository
30
53
  data
31
54
  rescue Psych::Exception, SystemCallError, IOError => e
32
55
  raise invalid(path, "cannot be read (#{e.message.lines.first.strip})")
@@ -75,21 +98,51 @@ module Gitflash
75
98
  end
76
99
  private_class_method :check_types!
77
100
 
101
+ # A repository's file comes with the clone, so its worktree_dir must stay next to the main
102
+ # checkout: relative, no `~`, and not climbing above the main checkout's parent
103
+ def self.check_repository_dir!(template, path)
104
+ return if template.nil? || ConfigKeys.contained?(template)
105
+
106
+ raise invalid(path, "'worktree_dir' must be a relative path that stays next to the " \
107
+ 'repository (set other places in ~/.config/gitflash.yml)')
108
+ end
109
+ private_class_method :check_repository_dir!
110
+
78
111
  def self.invalid(path, problem)
79
112
  UsageError.new("#{path} #{problem}", code: 'invalid_config')
80
113
  end
81
114
  private_class_method :invalid
82
115
 
83
- # True when the branch name matches a configured pattern (shell-style `*` and `?`)
116
+ # True when the branch name matches a configured pattern (shell-style `*`, `?`, `[abc]` and
117
+ # `{a,b}`)
84
118
  def protected?(name)
85
- protected_patterns.any? { |pattern| File.fnmatch?(pattern, name) }
119
+ protected_patterns.any? { |pattern| File.fnmatch?(pattern, name, File::FNM_EXTGLOB) }
120
+ end
121
+
122
+ # True when an untracked file must stay out of snapshots: the pattern matches its path or
123
+ # its name (`*.pem`, `.env*`, `secrets/*`)
124
+ def snapshot_excluded?(path)
125
+ flags = File::FNM_PATHNAME | File::FNM_DOTMATCH
126
+ names = excluded_candidates(path)
127
+ snapshot_exclude.any? { |pattern| names.any? { |name| File.fnmatch?(pattern, name, flags) } }
128
+ end
129
+
130
+ # The path, its name, and every directory above it: a pattern that matches a directory
131
+ # (`secrets`, `secrets/*`) covers everything below it, as in .gitignore
132
+ def excluded_candidates(path)
133
+ parts = path.split('/')
134
+ dirs = (1...parts.size).map { |count| parts.first(count).join('/') }
135
+ [path, File.basename(path), *dirs]
86
136
  end
87
137
 
88
- # Directory for a new worktree. `%{repo}` and `%{branch}` are replaced; a relative path
89
- # is resolved against the main checkout. Slashes in a branch name stay as folders.
138
+ # Directory for a new worktree. `%<repo>s` and `%<branch>s` are replaced as plain text; a
139
+ # relative path is resolved against the main checkout. Slashes in a branch name stay as
140
+ # folders.
90
141
  def worktree_path(root:, branch:)
91
- path = format(worktree_dir, repo: File.basename(root), branch: branch)
92
- File.expand_path(path, root)
142
+ path = worktree_dir.gsub('%<repo>s') { File.basename(root) }.gsub('%<branch>s') { branch }
143
+ path = File.expand_path(path, root)
144
+ ConfigKeys.check_contained!(path, root) if worktree_dir_from_repo
145
+ path
93
146
  end
94
147
  end
95
148
  end
@@ -7,13 +7,73 @@ module Gitflash
7
7
  USER_FILE = File.join('.config', 'gitflash.yml')
8
8
  DEFAULT_WORKTREE_DIR = '../%<repo>s.worktrees/%<branch>s'
9
9
  DEFAULTS = {
10
- 'protected' => [], 'stale_days' => 30, 'worktree_dir' => DEFAULT_WORKTREE_DIR
10
+ 'protected' => [], 'stale_days' => 30, 'worktree_dir' => DEFAULT_WORKTREE_DIR,
11
+ 'snapshot_exclude' => []
11
12
  }.freeze
13
+ PLACEHOLDERS = ['%<repo>s', '%<branch>s'].freeze
14
+ MAX_TEMPLATE_LENGTH = 512
12
15
  CHECKS = {
13
- 'protected' => [->(v) { v.is_a?(Array) && v.all?(String) },
14
- 'a list of branch name patterns'],
16
+ 'protected' => [->(v) { v.is_a?(Array) && v.all? { |pattern| branch_pattern?(pattern) } },
17
+ 'a list of branch name patterns (no spaces, control characters, ~, ^ or :)'],
15
18
  'stale_days' => [->(v) { v.is_a?(Integer) && v.positive? }, 'a positive whole number'],
16
- 'worktree_dir' => [->(v) { v.is_a?(String) && !v.empty? }, 'a non-empty string']
19
+ 'worktree_dir' => [->(v) { template?(v) },
20
+ 'a path of up to 512 characters that uses only %<repo>s and %<branch>s'],
21
+ 'snapshot_exclude' => [->(v) { v.is_a?(Array) && v.all?(String) },
22
+ 'a list of file name patterns']
17
23
  }.freeze
24
+
25
+ # Whether a template resolves inside the parent of the main checkout, for any repository and
26
+ # branch name (branch names cannot hold `..`)
27
+ def self.contained?(template)
28
+ return false if template.start_with?('/', '~')
29
+
30
+ root = '/parent/repo'
31
+ path = template.gsub('%<repo>s', 'repo').gsub('%<branch>s', 'branch')
32
+ File.expand_path(path, root).start_with?("#{File.dirname(root)}/")
33
+ end
34
+
35
+ # A pattern that can match a branch name: characters git refuses in branch names, such as a
36
+ # space at either end, would make it match nothing without any warning
37
+ def self.branch_pattern?(value)
38
+ return false unless value.is_a?(String) && !value.empty?
39
+ return false if value.match?(/[\x00-\x20\x7F~^:\\]/)
40
+
41
+ # `..` is never in a branch name; inside `{..,a}` it is only one of the alternatives
42
+ alternatives(value).any? { |pattern| !pattern.include?('..') }
43
+ end
44
+
45
+ # The patterns `{a,b}` stands for, innermost braces first, as File::FNM_EXTGLOB reads them
46
+ def self.alternatives(pattern)
47
+ match = pattern.match(/\{([^{}]*)\}/)
48
+ return [pattern] unless match
49
+
50
+ match[1].split(',', -1).flat_map do |choice|
51
+ alternatives(match.pre_match + choice + match.post_match)
52
+ end
53
+ end
54
+
55
+ # A path template: the two placeholders are the only `%` directives, so a width such as
56
+ # `%<repo>999999999s` can never make Ruby build a huge string
57
+ def self.template?(value)
58
+ return false unless value.is_a?(String)
59
+ return false if value.empty? || value.length > MAX_TEMPLATE_LENGTH
60
+
61
+ !PLACEHOLDERS.reduce(value) { |rest, placeholder| rest.gsub(placeholder, '') }.include?('%')
62
+ end
63
+
64
+ # A repository's worktree_dir passed a check on the text alone; a symbolic link in the
65
+ # repository (`wt-link -> /elsewhere`) could still lead out, so the real location of the
66
+ # deepest existing directory on the way must stay next to the main checkout too
67
+ def self.check_contained!(path, root)
68
+ parent = File.realpath(File.dirname(root))
69
+ existing = path
70
+ existing = File.dirname(existing) until File.exist?(existing)
71
+ real = File.join(File.realpath(existing), path.delete_prefix(existing))
72
+ return if real.start_with?("#{parent}/")
73
+
74
+ raise UsageError.new("worktree_dir in #{REPO_FILE} leads to #{real}, outside " \
75
+ "#{parent}; set worktree_dir in ~/.config/gitflash.yml to use it",
76
+ code: 'invalid_config')
77
+ end
18
78
  end
19
79
  end
@@ -2,10 +2,14 @@
2
2
 
3
3
  module Gitflash
4
4
  # Raised when a destructive command runs without a terminal and without --yes.
5
- # Carries the plan so the caller can review it and re-run with --yes.
5
+ # Carries the plan so the caller can review it and re-run with --yes, or, for a change
6
+ # gitflash undo cannot revert, the token to pass to --confirm.
6
7
  class ConfirmationRequired < Error
7
- def initialize(message = nil, plan: nil)
8
+ attr_reader :confirm_token
9
+
10
+ def initialize(message = nil, plan: nil, confirm_token: nil)
8
11
  super(message, code: 'confirmation_required', plan: plan)
12
+ @confirm_token = confirm_token
9
13
  end
10
14
 
11
15
  def exit_code
@@ -16,9 +16,12 @@ module Gitflash
16
16
  end
17
17
 
18
18
  # Runs a command without a shell and returns [stdout, stderr, success].
19
- # `env` adds environment variables for this command only.
19
+ # `env` adds environment variables for this command only. The executable is passed as a
20
+ # pair, which keeps Ruby from handing a lone string to a shell.
20
21
  def capture(*args, env: {})
21
- stdout, stderr, status = Open3.capture3(env, *args)
22
+ raise CommandError, 'no command given' if args.empty?
23
+
24
+ stdout, stderr, status = Open3.capture3(env, [args.first, args.first], *args.drop(1))
22
25
  [stdout, stderr, status.success?]
23
26
  rescue SystemCallError => e
24
27
  raise CommandError, "#{args.join(' ')} failed: #{e.message}"
@@ -1,30 +1,133 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'digest'
4
+ require 'fileutils'
5
+ require 'json'
6
+ require 'tmpdir'
7
+
3
8
  module Gitflash
4
9
  module Hook
5
10
  # Marks the branches an agent just created as agent work (see Ownership), so
6
11
  # `gitflash clean --agent` can find them. Runs in the PostToolUse hook, after the command:
7
12
  # the branch exists then, and Claude Code only runs it when the command succeeded.
8
13
  #
9
- # A branch is marked only when it is unmarked and its reflog says it was created a moment
10
- # ago ("branch: Created from ..."), so a branch that was already there is never claimed.
14
+ # Text in a command proves nothing (`false && git branch x` creates nothing), so a branch is
15
+ # only marked when it is new:
16
+ # - the PreToolUse hook records which of the branches the command names exist before it runs
17
+ # (`record`), and a branch that existed then is never marked;
18
+ # - its reflog must say it was created a moment ago ("branch: Created from ..."), which also
19
+ # covers a Claude Code that ran no PreToolUse hook;
20
+ # - it must be unmarked.
11
21
  class BranchMarker
12
22
  CREATED = 'branch: Created from'
13
23
  RECENT_SECONDS = 120
24
+ STALE_RECORD_SECONDS = 24 * 60 * 60
14
25
 
15
- def initialize(clock: -> { Time.now })
26
+ def initialize(clock: -> { Time.now }, state_dir: nil)
16
27
  @clock = clock
28
+ @state_dir = state_dir || File.join(Dir.tmpdir, "gitflash-hook-#{Process.uid}")
29
+ end
30
+
31
+ # PreToolUse: remember the branches the command may create that exist already
32
+ def record(command, cwd, tool_use_id)
33
+ creations = CommandParser.new(cwd).creations(command)
34
+ return if creations.empty? || (path = record_path(tool_use_id)).nil?
35
+ return unless private_state_dir?
36
+
37
+ prune_records
38
+ File.write(path, JSON.generate(existing_stamps(creations)))
17
39
  end
18
40
 
19
- # Branch names marked as agent work, in the directories the command ran in
20
- def call(command, cwd)
41
+ # PostToolUse: branch names marked as agent work, in the directories the command ran in
42
+ def call(command, cwd, tool_use_id = nil)
43
+ existed = take_record(tool_use_id)
21
44
  created = CommandParser.new(cwd).creations(command)
22
- created.select { |creation| mark(creation[:dir], creation[:branch]) }
45
+ created.reject { |creation| already_there?(existed, creation) }
46
+ .select { |creation| mark(creation[:dir], creation[:branch]) }
23
47
  .map { |creation| creation[:branch] }.uniq
24
48
  end
25
49
 
26
50
  private
27
51
 
52
+ # { key => stamp } of the named branches that exist now
53
+ def existing_stamps(creations)
54
+ existing = creations.select { |creation| branch_exists?(creation[:dir], creation[:branch]) }
55
+ existing.to_h { |creation| [key(creation), stamp(creation)] }
56
+ end
57
+
58
+ def key(creation)
59
+ "#{creation[:dir]}\0#{creation[:branch]}"
60
+ end
61
+
62
+ # A branch that was there before the command and is the same branch now. One that the
63
+ # command deleted and created again has a new reflog, so a new stamp, and counts as new.
64
+ # An empty stamp (no reflog) is no evidence of a new branch either: all that is known is
65
+ # that the branch existed, and claiming a branch a person may have made is the worse
66
+ # mistake, so it is left alone. Without a reflog nothing could be marked in any case.
67
+ def already_there?(existed, creation)
68
+ existed.key?(key(creation)) && existed[key(creation)] == stamp(creation)
69
+ end
70
+
71
+ # Time, commit and message of the oldest reflog entry, which is the creation of the branch;
72
+ # empty when the reflog is off
73
+ def stamp(creation)
74
+ return '' unless Dir.exist?(creation[:dir])
75
+
76
+ stdout, _stderr, success = Git::BashCommand.capture(
77
+ 'git', '-C', creation[:dir], 'log', '-g', '--format=%ct %H %gs',
78
+ "#{Repo::HEADS}#{creation[:branch]}", '--'
79
+ )
80
+ success ? stdout.lines(chomp: true).last.to_s : ''
81
+ end
82
+
83
+ # One file per tool call, named from a hash so the id can never form a path
84
+ def record_path(tool_use_id)
85
+ return nil if tool_use_id.to_s.empty?
86
+
87
+ File.join(@state_dir, "#{Digest::SHA256.hexdigest(tool_use_id.to_s)}.json")
88
+ end
89
+
90
+ def take_record(tool_use_id)
91
+ path = record_path(tool_use_id)
92
+ return {} unless path && private_state_dir? && File.file?(path)
93
+
94
+ record = JSON.parse(File.read(path))
95
+ record.is_a?(Hash) ? record : {}
96
+ rescue JSON::ParserError
97
+ {}
98
+ ensure
99
+ FileUtils.rm_f(path) if path
100
+ end
101
+
102
+ # The directory holds the records of this user only: created with mode 0700, and refused
103
+ # when it is a link, belongs to someone else or is open to the group or others (a shared
104
+ # /tmp lets another user create the name first)
105
+ def private_state_dir?
106
+ FileUtils.mkdir_p(@state_dir, mode: 0o700)
107
+ stat = File.lstat(@state_dir)
108
+ stat.directory? && stat.owned? && stat.mode.nobits?(0o077)
109
+ rescue SystemCallError
110
+ false
111
+ end
112
+
113
+ # Another hook may delete a record at the same moment: a vanished file is not an error
114
+ def prune_records
115
+ Dir.glob(File.join(@state_dir, '*.json')).each do |file|
116
+ FileUtils.rm_f(file) if @clock.call - File.mtime(file) > STALE_RECORD_SECONDS
117
+ rescue SystemCallError
118
+ next
119
+ end
120
+ end
121
+
122
+ def branch_exists?(dir, branch)
123
+ return false unless Dir.exist?(dir)
124
+
125
+ _stdout, _stderr, success = Git::BashCommand.capture(
126
+ 'git', '-C', dir, 'show-ref', '--verify', '--quiet', "#{Repo::HEADS}#{branch}"
127
+ )
128
+ success
129
+ end
130
+
28
131
  def mark(dir, branch)
29
132
  return false unless Dir.exist?(dir)
30
133
 
@@ -29,21 +29,39 @@ module Gitflash
29
29
  data = JSON.parse(input)
30
30
  return after_command(data) if data['hook_event_name'] == POST_EVENT
31
31
 
32
+ before_command(data)
33
+ end
34
+
35
+ private
36
+
37
+ # PreToolUse: remember which branches exist, then snapshot, ask or deny for risky commands
38
+ def before_command(data)
39
+ record_branches(data)
32
40
  targets = targets(data)
33
41
  return nil if targets.empty?
34
42
  return deny(targets) if @mode == 'deny'
35
43
 
36
- saved = merge_by_dir(targets).filter_map { |target| save(target) }
37
- saved.empty? ? nil : output(saved)
44
+ merged = merge_by_dir(targets)
45
+ saved = merged.filter_map { |target| save(target) }
46
+ # Asking is the user's protection: it must not depend on a snapshot having been saved.
47
+ # A command that runs where nothing could be saved is always reported to the agent.
48
+ output(saved, merged - saved.map(&:first))
38
49
  end
39
50
 
40
- private
51
+ # Recording is a side feature: when it fails, snapshots and the ask or deny decision still run
52
+ def record_branches(data)
53
+ return unless bash?(data)
54
+
55
+ @marker.record(command_of(data), data['cwd'] || Dir.pwd, data['tool_use_id'])
56
+ rescue StandardError => e
57
+ warn "gitflash hook: could not record branches (#{e.message})"
58
+ end
41
59
 
42
60
  # PostToolUse: mark the branches the command created, and tell Claude
43
61
  def after_command(data)
44
62
  return nil unless data['tool_name'] == 'Bash'
45
63
 
46
- marked = @marker.call(data.dig('tool_input', 'command').to_s, data['cwd'] || Dir.pwd)
64
+ marked = @marker.call(command_of(data), data['cwd'] || Dir.pwd, data['tool_use_id'])
47
65
  return nil if marked.empty?
48
66
 
49
67
  context = "gitflash marked #{marked.join(', ')} as agent work, so " \
@@ -52,24 +70,27 @@ module Gitflash
52
70
  { hookSpecificOutput: { hookEventName: POST_EVENT, additionalContext: context } }
53
71
  end
54
72
 
73
+ def bash?(data)
74
+ data['tool_name'] == 'Bash'
75
+ end
76
+
77
+ def command_of(data)
78
+ data.dig('tool_input', 'command').to_s
79
+ end
80
+
55
81
  def targets(data)
56
- return [] unless data['tool_name'] == 'Bash'
82
+ return [] unless bash?(data)
57
83
 
58
- command = data.dig('tool_input', 'command').to_s
59
- CommandParser.new(data['cwd'] || Dir.pwd).targets(command)
84
+ CommandParser.new(data['cwd'] || Dir.pwd).targets(command_of(data))
60
85
  end
61
86
 
62
87
  # One snapshot per directory, covering every command run there
63
88
  def merge_by_dir(targets)
64
- targets.group_by(&:dir).map do |dir, group|
65
- CommandParser::Target.new(
66
- dir: dir, command: group.map(&:command).join(' && '),
67
- scope: group.flat_map(&:scope).uniq, branches: group.flat_map(&:branches).uniq
68
- )
69
- end
89
+ targets.group_by(&:dir).map { |dir, group| CommandParser::Target.merge(dir, group) }
70
90
  end
71
91
 
72
- # [target, snapshot], or nil when the directory is not a git work tree
92
+ # [target, snapshot], or nil when the directory is not a git work tree or saving failed.
93
+ # A failure must not stop the ask decision, so it is warned about here and not raised.
73
94
  def save(target)
74
95
  return nil unless Dir.exist?(target.dir)
75
96
 
@@ -80,6 +101,9 @@ module Gitflash
80
101
  scope: target.scope, branches: branches(target))
81
102
  [target, snapshot]
82
103
  end
104
+ rescue StandardError => e
105
+ warn "gitflash hook: could not save a snapshot in #{target.dir} (#{e.message})"
106
+ nil
83
107
  end
84
108
 
85
109
  # Full ref shortened by removing refs/heads/ only; `--short` changes when a tag has the name
@@ -87,17 +111,18 @@ module Gitflash
87
111
  git('symbolic-ref', '--quiet', 'HEAD')&.delete_prefix(Repo::HEADS)
88
112
  end
89
113
 
114
+ # The branches to save; nil (all of them) when the command does not name its branches, as
115
+ # in `xargs git branch -D < list`
90
116
  def branches(target)
117
+ return nil if target.branches.empty?
118
+
91
119
  target.branches.filter_map do |name|
92
120
  name == :current ? current_branch : name
93
121
  end
94
122
  end
95
123
 
96
- def output(saved)
97
- notes = saved.map { |target, snap| "snapshot #{snap.id} before `#{target.command}`" }
98
- context = "gitflash saved #{notes.join('; ')}. If this discards work that was still " \
99
- "needed, restore it with `gitflash undo #{saved.last[1].id}` " \
100
- '(list snapshots with `gitflash snapshots`).'
124
+ def output(saved, unsaved)
125
+ context = Messages.context(saved, unsaved)
101
126
  specific = { hookEventName: EVENT, additionalContext: context }
102
127
  if @mode == 'ask'
103
128
  specific[:permissionDecision] = 'ask'
@@ -108,12 +133,8 @@ module Gitflash
108
133
  end
109
134
 
110
135
  def deny(targets)
111
- commands = targets.map { |target| "`#{target.command}`" }.join(', ')
112
- reason = "gitflash blocked #{commands}: it can discard work git cannot restore. " \
113
- 'Use the gitflash equivalent (gitflash reset, gitflash delete, ...), which ' \
114
- 'saves a snapshot first, or run `gitflash snapshot` before retrying.'
115
136
  { hookSpecificOutput: { hookEventName: EVENT, permissionDecision: 'deny',
116
- permissionDecisionReason: reason } }
137
+ permissionDecisionReason: Messages.denial(targets) } }
117
138
  end
118
139
 
119
140
  # Stripped stdout of a git command, or nil when it fails