gitflash 0.6.0 → 0.7.0.beta2

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/README.md +26 -2
  4. data/command_descriptions.yml +80 -0
  5. data/lib/gitflash/branch.rb +7 -2
  6. data/lib/gitflash/cherry_pick/log_parser.rb +63 -0
  7. data/lib/gitflash/cherry_pick.rb +120 -0
  8. data/lib/gitflash/cleanup.rb +70 -0
  9. data/lib/gitflash/cli.rb +38 -1
  10. data/lib/gitflash/commands/base.rb +12 -3
  11. data/lib/gitflash/commands/branches.rb +1 -0
  12. data/lib/gitflash/commands/clean.rb +51 -0
  13. data/lib/gitflash/commands/clean_criteria.rb +29 -0
  14. data/lib/gitflash/commands/delete.rb +17 -5
  15. data/lib/gitflash/commands/hook_install.rb +31 -10
  16. data/lib/gitflash/commands/hook_status.rb +24 -6
  17. data/lib/gitflash/commands/mark.rb +85 -0
  18. data/lib/gitflash/commands/pick.rb +143 -0
  19. data/lib/gitflash/commands/pick_control.rb +67 -0
  20. data/lib/gitflash/commands/pick_outcome.rb +52 -0
  21. data/lib/gitflash/commands/removal_report.rb +70 -0
  22. data/lib/gitflash/commands/undo.rb +41 -6
  23. data/lib/gitflash/commands/worktree_add.rb +100 -0
  24. data/lib/gitflash/commands/worktree_clean.rb +49 -0
  25. data/lib/gitflash/commands/worktree_command.rb +28 -0
  26. data/lib/gitflash/commands/worktree_list.rb +48 -0
  27. data/lib/gitflash/commands/worktree_lock.rb +58 -0
  28. data/lib/gitflash/commands/worktree_move.rb +52 -0
  29. data/lib/gitflash/commands/worktree_prune.rb +41 -0
  30. data/lib/gitflash/commands/worktree_remove.rb +126 -0
  31. data/lib/gitflash/commands/worktree_unlock.rb +12 -0
  32. data/lib/gitflash/config.rb +98 -0
  33. data/lib/gitflash/config_keys.rb +31 -0
  34. data/lib/gitflash/git/bash_command.rb +5 -2
  35. data/lib/gitflash/git/in_directory.rb +29 -0
  36. data/lib/gitflash/hook/branch_marker.rb +155 -0
  37. data/lib/gitflash/hook/claude.rb +54 -18
  38. data/lib/gitflash/hook/command_parser.rb +50 -12
  39. data/lib/gitflash/hook/creations.rb +85 -0
  40. data/lib/gitflash/hook/messages.rb +38 -0
  41. data/lib/gitflash/hook/rules.rb +6 -4
  42. data/lib/gitflash/hook/settings.rb +18 -5
  43. data/lib/gitflash/hook_cli.rb +20 -5
  44. data/lib/gitflash/ownership.rb +59 -0
  45. data/lib/gitflash/protection.rb +19 -0
  46. data/lib/gitflash/repo.rb +5 -5
  47. data/lib/gitflash/restore.rb +8 -7
  48. data/lib/gitflash/state_capture.rb +10 -5
  49. data/lib/gitflash/terminal_text.rb +9 -0
  50. data/lib/gitflash/ui.rb +1 -1
  51. data/lib/gitflash/version.rb +1 -1
  52. data/lib/gitflash/worktree.rb +32 -0
  53. data/lib/gitflash/worktree_cli.rb +115 -0
  54. data/lib/gitflash/worktree_revival.rb +55 -0
  55. data/lib/gitflash/worktrees.rb +147 -0
  56. data/schema/v1.json +1050 -2
  57. metadata +32 -1
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Gitflash
6
+ # Settings from `.gitflash.yml` in the main checkout and `~/.config/gitflash.yml`.
7
+ # Precedence: repository file, then user file, then built-in defaults.
8
+ # Command-line flags are applied by the commands themselves and win over all of these.
9
+ # 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. The repository file wins
12
+ # key by key, except `protected`: both files add patterns, so a repository can protect more
13
+ # branches but never remove what the user protects.
14
+ def self.load(root:, home: Dir.home)
15
+ layers = [File.join(home, ConfigKeys::USER_FILE), File.join(root, ConfigKeys::REPO_FILE)]
16
+ .map { |path| read(path) }
17
+ values = layers.reduce(ConfigKeys::DEFAULTS) { |merged, layer| merged.merge(layer) }
18
+ new(protected_patterns: layers.flat_map { |layer| layer.fetch('protected', []) }.uniq,
19
+ stale_days: values['stale_days'], worktree_dir: values['worktree_dir'])
20
+ end
21
+
22
+ def self.defaults
23
+ new(protected_patterns: [], stale_days: 30, worktree_dir: ConfigKeys::DEFAULT_WORKTREE_DIR)
24
+ end
25
+
26
+ def self.read(path)
27
+ return {} unless File.file?(path)
28
+
29
+ reject_duplicate_keys!(path)
30
+ data = YAML.safe_load_file(path) || {}
31
+ validate!(data, path)
32
+ data
33
+ rescue Psych::Exception, SystemCallError, IOError => e
34
+ raise invalid(path, "cannot be read (#{e.message.lines.first.strip})")
35
+ end
36
+ private_class_method :read
37
+
38
+ # A YAML loader keeps only the last of two equal keys, which could drop a `protected` list
39
+ def self.reject_duplicate_keys!(path)
40
+ root = Psych.parse_file(path)
41
+ return unless root
42
+
43
+ duplicate = duplicate_key(root)
44
+ raise invalid(path, "has the key '#{duplicate}' more than once") if duplicate
45
+ end
46
+ private_class_method :reject_duplicate_keys!
47
+
48
+ def self.duplicate_key(node)
49
+ own = node.is_a?(Psych::Nodes::Mapping) ? own_duplicate(node) : nil
50
+ own || (node.children || []).filter_map { |child| duplicate_key(child) }.first
51
+ end
52
+ private_class_method :duplicate_key
53
+
54
+ def self.own_duplicate(mapping)
55
+ keys = mapping.children.each_slice(2).filter_map do |key, _value|
56
+ key.value if key.respond_to?(:value)
57
+ end
58
+ keys.tally.find { |_key, count| count > 1 }&.first
59
+ end
60
+ private_class_method :own_duplicate
61
+
62
+ def self.validate!(data, path)
63
+ raise invalid(path, 'must be a mapping') unless data.is_a?(Hash)
64
+
65
+ unknown = data.keys - ConfigKeys::DEFAULTS.keys
66
+ raise invalid(path, "has unknown keys: #{unknown.join(', ')}") if unknown.any?
67
+
68
+ check_types!(data, path)
69
+ end
70
+ private_class_method :validate!
71
+
72
+ def self.check_types!(data, path)
73
+ data.each do |key, value|
74
+ check, expected = ConfigKeys::CHECKS.fetch(key)
75
+ raise invalid(path, "'#{key}' must be #{expected}") unless check.call(value)
76
+ end
77
+ end
78
+ private_class_method :check_types!
79
+
80
+ def self.invalid(path, problem)
81
+ UsageError.new("#{path} #{problem}", code: 'invalid_config')
82
+ end
83
+ private_class_method :invalid
84
+
85
+ # True when the branch name matches a configured pattern (shell-style `*` and `?`)
86
+ def protected?(name)
87
+ protected_patterns.any? { |pattern| File.fnmatch?(pattern, name) }
88
+ end
89
+
90
+ # Directory for a new worktree. `%<repo>s` and `%<branch>s` are replaced as plain text; a
91
+ # relative path is resolved against the main checkout. Slashes in a branch name stay as
92
+ # folders.
93
+ def worktree_path(root:, branch:)
94
+ path = worktree_dir.gsub('%<repo>s') { File.basename(root) }.gsub('%<branch>s') { branch }
95
+ File.expand_path(path, root)
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ # Built-in defaults, files and the check each key must pass
5
+ module ConfigKeys
6
+ REPO_FILE = '.gitflash.yml'
7
+ USER_FILE = File.join('.config', 'gitflash.yml')
8
+ DEFAULT_WORKTREE_DIR = '../%<repo>s.worktrees/%<branch>s'
9
+ DEFAULTS = {
10
+ 'protected' => [], 'stale_days' => 30, 'worktree_dir' => DEFAULT_WORKTREE_DIR
11
+ }.freeze
12
+ PLACEHOLDERS = ['%<repo>s', '%<branch>s'].freeze
13
+ MAX_TEMPLATE_LENGTH = 512
14
+ CHECKS = {
15
+ 'protected' => [->(v) { v.is_a?(Array) && v.all?(String) },
16
+ 'a list of branch name patterns'],
17
+ 'stale_days' => [->(v) { v.is_a?(Integer) && v.positive? }, 'a positive whole number'],
18
+ 'worktree_dir' => [->(v) { template?(v) },
19
+ 'a path of up to 512 characters that uses only %<repo>s and %<branch>s']
20
+ }.freeze
21
+
22
+ # A path template: the two placeholders are the only `%` directives, so a width such as
23
+ # `%<repo>999999999s` can never make Ruby build a huge string
24
+ def self.template?(value)
25
+ return false unless value.is_a?(String)
26
+ return false if value.empty? || value.length > MAX_TEMPLATE_LENGTH
27
+
28
+ !PLACEHOLDERS.reduce(value) { |rest, placeholder| rest.gsub(placeholder, '') }.include?('%')
29
+ end
30
+ end
31
+ end
@@ -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}"
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Git
5
+ # Runs every git command inside another directory (`git -C DIR ...`). It has the same
6
+ # interface as BashCommand, so StateCapture, Snapshots and Restore can work on a linked
7
+ # worktree without knowing about it.
8
+ class InDirectory
9
+ def initialize(path, bash: BashCommand)
10
+ @path = path
11
+ @bash = bash
12
+ end
13
+
14
+ def exec(*args, env: {})
15
+ @bash.exec(*scoped(args), env: env)
16
+ end
17
+
18
+ def capture(*args, env: {})
19
+ @bash.capture(*scoped(args), env: env)
20
+ end
21
+
22
+ private
23
+
24
+ def scoped(args)
25
+ args.first == 'git' ? ['git', '-C', @path, *args.drop(1)] : args
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'fileutils'
5
+ require 'json'
6
+ require 'tmpdir'
7
+
8
+ module Gitflash
9
+ module Hook
10
+ # Marks the branches an agent just created as agent work (see Ownership), so
11
+ # `gitflash clean --agent` can find them. Runs in the PostToolUse hook, after the command:
12
+ # the branch exists then, and Claude Code only runs it when the command succeeded.
13
+ #
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.
21
+ class BranchMarker
22
+ CREATED = 'branch: Created from'
23
+ RECENT_SECONDS = 120
24
+ STALE_RECORD_SECONDS = 24 * 60 * 60
25
+
26
+ def initialize(clock: -> { Time.now }, state_dir: nil)
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)))
39
+ end
40
+
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)
44
+ created = CommandParser.new(cwd).creations(command)
45
+ created.reject { |creation| already_there?(existed, creation) }
46
+ .select { |creation| mark(creation[:dir], creation[:branch]) }
47
+ .map { |creation| creation[:branch] }.uniq
48
+ end
49
+
50
+ private
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
+
131
+ def mark(dir, branch)
132
+ return false unless Dir.exist?(dir)
133
+
134
+ Dir.chdir(dir) do
135
+ ownership = Ownership.new
136
+ next false unless created_recently?(branch) && !ownership.all.key?(branch)
137
+
138
+ ownership.mark(branch, 'agent')
139
+ true
140
+ end
141
+ end
142
+
143
+ # The oldest reflog entry of the branch is its creation, and it is recent
144
+ def created_recently?(branch)
145
+ stdout, _stderr, success = Git::BashCommand.capture(
146
+ 'git', 'log', '-g', '--format=%ct%x1f%gs', "#{Repo::HEADS}#{branch}", '--'
147
+ )
148
+ return false unless success
149
+
150
+ created, subject = stdout.lines(chomp: true).last.to_s.split("\x1f", 2)
151
+ subject.to_s.start_with?(CREATED) && @clock.call.to_i - created.to_i <= RECENT_SECONDS
152
+ end
153
+ end
154
+ end
155
+ end
@@ -4,7 +4,8 @@ require 'json'
4
4
 
5
5
  module Gitflash
6
6
  module Hook
7
- # Claude Code PreToolUse hook for the Bash tool.
7
+ # Claude Code hook for the Bash tool. PostToolUse input marks the branches the command
8
+ # created as agent work (BranchMarker); everything below describes PreToolUse.
8
9
  #
9
10
  # Reads the hook input (JSON on stdin) and, when the command can lose work git cannot
10
11
  # restore, acts according to the mode:
@@ -15,28 +16,70 @@ module Gitflash
15
16
  class Claude
16
17
  MODES = %w[snapshot ask deny].freeze
17
18
  EVENT = 'PreToolUse'
19
+ POST_EVENT = 'PostToolUse'
18
20
 
19
- def initialize(mode: 'snapshot', snapshots: -> { Gitflash::Snapshots.new })
21
+ def initialize(mode: 'snapshot', snapshots: -> { Gitflash::Snapshots.new },
22
+ marker: BranchMarker.new)
20
23
  @mode = mode
21
24
  @snapshots = snapshots
25
+ @marker = marker
22
26
  end
23
27
 
24
28
  def call(input)
25
- targets = targets(JSON.parse(input))
29
+ data = JSON.parse(input)
30
+ return after_command(data) if data['hook_event_name'] == POST_EVENT
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)
40
+ targets = targets(data)
26
41
  return nil if targets.empty?
27
42
  return deny(targets) if @mode == 'deny'
28
43
 
29
44
  saved = merge_by_dir(targets).filter_map { |target| save(target) }
30
- saved.empty? ? nil : output(saved)
45
+ # Asking is the user's protection: it must not depend on a snapshot having been saved
46
+ saved.empty? && @mode != 'ask' ? nil : output(saved, targets)
31
47
  end
32
48
 
33
- private
49
+ # Recording is a side feature: when it fails, snapshots and the ask or deny decision still run
50
+ def record_branches(data)
51
+ return unless bash?(data)
52
+
53
+ @marker.record(command_of(data), data['cwd'] || Dir.pwd, data['tool_use_id'])
54
+ rescue StandardError => e
55
+ warn "gitflash hook: could not record branches (#{e.message})"
56
+ end
57
+
58
+ # PostToolUse: mark the branches the command created, and tell Claude
59
+ def after_command(data)
60
+ return nil unless data['tool_name'] == 'Bash'
61
+
62
+ marked = @marker.call(command_of(data), data['cwd'] || Dir.pwd, data['tool_use_id'])
63
+ return nil if marked.empty?
64
+
65
+ context = "gitflash marked #{marked.join(', ')} as agent work, so " \
66
+ '`gitflash clean --agent` can find it. Change it with ' \
67
+ '`gitflash mark BRANCH --owner human`.'
68
+ { hookSpecificOutput: { hookEventName: POST_EVENT, additionalContext: context } }
69
+ end
70
+
71
+ def bash?(data)
72
+ data['tool_name'] == 'Bash'
73
+ end
74
+
75
+ def command_of(data)
76
+ data.dig('tool_input', 'command').to_s
77
+ end
34
78
 
35
79
  def targets(data)
36
- return [] unless data['tool_name'] == 'Bash'
80
+ return [] unless bash?(data)
37
81
 
38
- command = data.dig('tool_input', 'command').to_s
39
- CommandParser.new(data['cwd'] || Dir.pwd).targets(command)
82
+ CommandParser.new(data['cwd'] || Dir.pwd).targets(command_of(data))
40
83
  end
41
84
 
42
85
  # One snapshot per directory, covering every command run there
@@ -73,11 +116,8 @@ module Gitflash
73
116
  end
74
117
  end
75
118
 
76
- def output(saved)
77
- notes = saved.map { |target, snap| "snapshot #{snap.id} before `#{target.command}`" }
78
- context = "gitflash saved #{notes.join('; ')}. If this discards work that was still " \
79
- "needed, restore it with `gitflash undo #{saved.last[1].id}` " \
80
- '(list snapshots with `gitflash snapshots`).'
119
+ def output(saved, targets)
120
+ context = saved.empty? ? Messages.unsaved(targets) : Messages.saved(saved)
81
121
  specific = { hookEventName: EVENT, additionalContext: context }
82
122
  if @mode == 'ask'
83
123
  specific[:permissionDecision] = 'ask'
@@ -88,12 +128,8 @@ module Gitflash
88
128
  end
89
129
 
90
130
  def deny(targets)
91
- commands = targets.map { |target| "`#{target.command}`" }.join(', ')
92
- reason = "gitflash blocked #{commands}: it can discard work git cannot restore. " \
93
- 'Use the gitflash equivalent (gitflash reset, gitflash delete, ...), which ' \
94
- 'saves a snapshot first, or run `gitflash snapshot` before retrying.'
95
131
  { hookSpecificOutput: { hookEventName: EVENT, permissionDecision: 'deny',
96
- permissionDecisionReason: reason } }
132
+ permissionDecisionReason: Messages.denial(targets) } }
97
133
  end
98
134
 
99
135
  # Stripped stdout of a git command, or nil when it fails
@@ -17,27 +17,68 @@ module Gitflash
17
17
  Target = Data.define(:dir, :command, :scope, :branches)
18
18
 
19
19
  PREFIXES = %w[sudo command exec time nohup env].freeze
20
+ SHELLS = %w[bash sh zsh dash ksh].freeze
21
+ MAX_NESTING = 3
20
22
  OPTIONS_WITH_VALUE = %w[-c --git-dir --work-tree --namespace --exec-path --config-env].freeze
21
23
 
22
24
  def initialize(cwd)
23
25
  @cwd = cwd
24
26
  end
25
27
 
28
+ # Git commands that can lose work, with what to save before them
26
29
  def targets(command_line)
27
- dir = @cwd
28
- segments(command_line).filter_map do |segment|
30
+ git_commands(command_line).filter_map do |git|
31
+ rule = Rules.for(git.subcommand, git.args, git.dir)
32
+ next unless rule
33
+
34
+ Target.new(dir: rule.fetch(:dir, git.dir), command: git.command, scope: rule[:scope],
35
+ branches: rule[:branches] || [])
36
+ end
37
+ end
38
+
39
+ # Branches that git commands in the line create, as { dir:, branch: }
40
+ def creations(command_line)
41
+ git_commands(command_line).filter_map do |git|
42
+ branch = Creations.for(git.subcommand, git.args)
43
+ { dir: git.dir, branch: branch } if branch
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ # A git command found in the line: the directory it runs in (after `cd` and `-C`), the
50
+ # subcommand and its arguments
51
+ GitCommand = Data.define(:dir, :command, :subcommand, :args)
52
+
53
+ def git_commands(command_line, dir = @cwd, depth = 0)
54
+ segments(command_line).flat_map do |segment|
29
55
  words = words(segment)
30
- next if words.empty?
56
+ next [] if words.empty?
31
57
 
32
58
  if words.first == 'cd'
33
59
  dir = File.expand_path(words[1] || Dir.home, dir)
34
- next
60
+ next []
35
61
  end
36
- git_target(words, dir, segment.strip) if File.basename(words.first) == 'git'
62
+ commands_in(words, dir, segment.strip, depth)
37
63
  end
38
64
  end
39
65
 
40
- private
66
+ # The git command of one segment, or the ones inside `bash -c "..."` and `eval "..."`
67
+ def commands_in(words, dir, segment, depth)
68
+ return [git_command(words, dir, segment)] if File.basename(words.first) == 'git'
69
+
70
+ script = nested_script(words)
71
+ script && depth < MAX_NESTING ? git_commands(script, dir, depth + 1) : []
72
+ end
73
+
74
+ # The script text passed to a shell (`bash -c`, `sh -lc`) or to `eval`
75
+ def nested_script(words)
76
+ return words.drop(1).join(' ') if words.first == 'eval'
77
+ return nil unless SHELLS.include?(File.basename(words.first))
78
+
79
+ index = words.index { |word| word.match?(/\A-[a-z]*c[a-z]*\z/) }
80
+ index && words[index + 1]
81
+ end
41
82
 
42
83
  # Splits on unquoted ; & | and newlines
43
84
  def segments(line)
@@ -78,13 +119,10 @@ module Gitflash
78
119
  word.delete_prefix('(').delete_prefix('{').delete_suffix(')').delete_suffix('}')
79
120
  end
80
121
 
81
- def git_target(words, dir, command)
122
+ def git_command(words, dir, command)
82
123
  index, dir = skip_git_options(words, dir)
83
- rule = Rules.for(words[index], words[(index + 1)..] || [], dir)
84
- return nil unless rule
85
-
86
- Target.new(dir: rule.fetch(:dir, dir), command: command, scope: rule[:scope],
87
- branches: rule[:branches] || [])
124
+ GitCommand.new(dir: dir, command: command, subcommand: words[index],
125
+ args: words[(index + 1)..] || [])
88
126
  end
89
127
 
90
128
  # Index of the subcommand, and the directory after any -C options
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Hook
5
+ # Which git commands create a branch, and the name of it: `checkout -b`, `switch -c`,
6
+ # `branch NAME` and `worktree add -b` (or a path without a commit, which names the branch
7
+ # after the directory). Returns the name, or nil when the command creates no branch.
8
+ module Creations
9
+ NOT_CREATING = %w[
10
+ -d -D --delete -m -M --move -c -C --copy -u --set-upstream-to --unset-upstream
11
+ --edit-description --list -a --all -r --remotes -v -vv --verbose --show-current --contains
12
+ --no-contains --merged --no-merged --points-at
13
+ ].freeze
14
+ WORKTREE_VALUE_OPTIONS = %w[-b -B --reason --orphan].freeze
15
+
16
+ module_function
17
+
18
+ def for(subcommand, args)
19
+ case subcommand
20
+ when 'checkout' then flagged(args, %w[-b -B])
21
+ when 'switch' then flagged(args, %w[-c -C --create --force-create])
22
+ when 'branch' then plain_branch(args)
23
+ when 'worktree' then worktree(args)
24
+ end
25
+ end
26
+
27
+ # The value of the first of `flags`: `-b NAME`, `-bNAME` or `--create=NAME`
28
+ def flagged(args, flags)
29
+ args.each_with_index do |arg, index|
30
+ return args[index + 1] if flags.include?(arg) && args[index + 1]
31
+
32
+ value = attached_value(arg, flags)
33
+ return value if value
34
+ end
35
+ nil
36
+ end
37
+
38
+ def attached_value(arg, flags)
39
+ flags.each do |flag|
40
+ if flag.start_with?('--')
41
+ return arg.delete_prefix("#{flag}=") if arg.start_with?("#{flag}=")
42
+ elsif arg.start_with?(flag) && arg.length > flag.length && !arg.start_with?('--')
43
+ return arg.delete_prefix(flag)
44
+ end
45
+ end
46
+ nil
47
+ end
48
+
49
+ # `git branch NAME [START]` creates NAME; listing, deleting or renaming flags mean it does not
50
+ def plain_branch(args)
51
+ return nil if args.intersect?(NOT_CREATING)
52
+
53
+ args.find { |arg| !arg.start_with?('-') }
54
+ end
55
+
56
+ def worktree(args)
57
+ return nil unless args.first == 'add'
58
+
59
+ rest = args.drop(1)
60
+ named = flagged(rest, %w[-b -B])
61
+ return named if named
62
+ return nil if rest.include?('--detach') || rest.include?('--orphan')
63
+
64
+ path = positional(rest)
65
+ path.size == 1 ? File.basename(path.first) : nil
66
+ end
67
+
68
+ # Arguments that are not options or the values of options that take one
69
+ def positional(args)
70
+ result = []
71
+ skip = false
72
+ args.each do |arg|
73
+ if skip
74
+ skip = false
75
+ elsif WORKTREE_VALUE_OPTIONS.include?(arg)
76
+ skip = true
77
+ elsif !arg.start_with?('-')
78
+ result << arg
79
+ end
80
+ end
81
+ result
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,38 @@
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
+ def saved(saved)
10
+ notes = saved.map { |target, snap| "snapshot #{snap.id} before `#{target.command}`" }
11
+ "gitflash saved #{notes.join('; ')}. If this discards work that was still needed, " \
12
+ "restore it with #{undo_commands(saved)} (list snapshots with `gitflash snapshots`)."
13
+ end
14
+
15
+ # One command per snapshot: with several directories each snapshot belongs to its own
16
+ def undo_commands(saved)
17
+ return "`gitflash undo #{saved.first[1].id}`" if saved.size == 1
18
+
19
+ saved.map { |target, snap| "`gitflash undo #{snap.id}` (run in #{target.dir})" }.join(', ')
20
+ end
21
+
22
+ 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`.'
25
+ end
26
+
27
+ def denial(targets)
28
+ "gitflash blocked #{commands(targets)}: it can discard work git cannot restore. " \
29
+ 'Use the gitflash equivalent (gitflash reset, gitflash delete, ...), which ' \
30
+ 'saves a snapshot first, or run `gitflash snapshot` before retrying.'
31
+ end
32
+
33
+ def commands(targets)
34
+ targets.map { |target| "`#{target.command}`" }.join(', ')
35
+ end
36
+ end
37
+ end
38
+ end