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
@@ -2,15 +2,15 @@
2
2
 
3
3
  module Gitflash
4
4
  module Commands
5
- # Restores a snapshot (the latest one by default). The current state is saved first,
6
- # so an undo can itself be undone.
5
+ # Restores a snapshot (by default the latest one taken in this worktree). The current state
6
+ # is saved first, so an undo can itself be undone.
7
7
  class Undo < Base
8
8
  def call(id = nil)
9
9
  snapshot = find(id)
10
10
  return no_snapshots unless snapshot
11
11
 
12
12
  restore = build_restore(snapshot)
13
- plan = restore.plan.merge(snapshot: snapshot.id)
13
+ plan = restore.plan.merge(snapshot: snapshot.id, reason: snapshot.reason)
14
14
  return nothing_to_undo(plan) if restore.empty?
15
15
 
16
16
  restore!(snapshot, restore, plan)
@@ -18,12 +18,19 @@ module Gitflash
18
18
 
19
19
  private
20
20
 
21
+ # Snapshots are shared by all worktrees of the repository, and another agent may have
22
+ # taken the newest one elsewhere; without an ID only this worktree's count
21
23
  def find(id)
22
- return snapshots.list.first if id.nil?
24
+ return snapshots.list.find { |snapshot| own?(snapshot) } if id.nil?
23
25
 
24
26
  snapshots.find(id) || usage_error!('unknown_snapshot', "Unknown snapshot '#{id}'")
25
27
  end
26
28
 
29
+ # Taken in this worktree; snapshots from before worktree_path was recorded count too
30
+ def own?(snapshot)
31
+ snapshot.worktree_path.nil? || snapshot.worktree_path == repo.toplevel
32
+ end
33
+
27
34
  # A snapshot of a worktree that is gone is restored by adding the worktree again
28
35
  def build_restore(snapshot)
29
36
  return WorktreeRevival.new(snapshot, repo: repo) if removed_worktree?(snapshot)
@@ -34,14 +41,14 @@ module Gitflash
34
41
 
35
42
  # The snapshot was taken in a worktree whose directory no longer exists
36
43
  def removed_worktree?(snapshot)
37
- (snapshot.scope?('head') || snapshot.scope?('worktree')) &&
38
- snapshot.worktree_path != repo.toplevel && !File.exist?(snapshot.worktree_path)
44
+ !own?(snapshot) && (snapshot.scope?('head') || snapshot.scope?('worktree')) &&
45
+ !File.exist?(snapshot.worktree_path)
39
46
  end
40
47
 
41
48
  # Branches are shared between worktrees; HEAD and files belong to one worktree
42
49
  def check_worktree!(snapshot)
43
50
  return unless snapshot.scope?('head') || snapshot.scope?('worktree')
44
- return if snapshot.worktree_path == repo.toplevel
51
+ return if own?(snapshot)
45
52
 
46
53
  usage_error!('wrong_worktree', "Snapshot #{snapshot.id} was taken in " \
47
54
  "#{snapshot.worktree_path}; run gitflash undo there")
@@ -86,7 +93,10 @@ module Gitflash
86
93
  end
87
94
 
88
95
  def no_snapshots
89
- ui.report(status: 'noop', result: { snapshot: nil }, text: 'No snapshots to undo')
96
+ ui.report(status: 'noop', result: { snapshot: nil },
97
+ text: 'No snapshots of this worktree to undo. `gitflash snapshots` lists all: ' \
98
+ 'run `gitflash undo ID` in the worktree a snapshot was taken in (an ' \
99
+ 'ID from elsewhere works here only for a branches-only snapshot)')
90
100
  end
91
101
 
92
102
  def nothing_to_undo(plan)
@@ -11,10 +11,24 @@ module Gitflash
11
11
  end
12
12
 
13
13
  # The worktree named by a path or a branch; raises `unknown_worktree` when none matches
14
+ # A target that names one worktree by path and another by branch is refused, so a command
15
+ # never acts on the worktree the caller did not mean
14
16
  def find!(target, list = worktrees.list)
15
- path = File.expand_path(target)
16
- list.find { |worktree| worktree.path == path || worktree.branch == target } ||
17
- usage_error!('unknown_worktree', "Unknown worktree: #{target}")
17
+ path = expand(target)
18
+ found = list.select { |worktree| worktree.path == path || worktree.branch == target }
19
+ usage_error!('unknown_worktree', "Unknown worktree: #{target}") if found.empty?
20
+ return found.first if found.one?
21
+
22
+ paths = found.map { |worktree| TerminalText.line(worktree.path) }.join(', ')
23
+ usage_error!('invalid_usage', "#{target} names more than one worktree (#{paths}); " \
24
+ 'pass the full path')
25
+ end
26
+
27
+ # File.expand_path, but `~name` for an unknown user is a usage error, not a crash
28
+ def expand(path)
29
+ File.expand_path(path)
30
+ rescue ArgumentError => e
31
+ usage_error!('invalid_options', "Cannot read the path #{path}: #{e.message}")
18
32
  end
19
33
 
20
34
  def refuse_main!(worktree, action)
@@ -6,7 +6,8 @@ module Gitflash
6
6
  # The main checkout, the worktree gitflash runs in, locked and dirty worktrees are refused
7
7
  # unless --force is given (the main checkout and the current one never can be removed).
8
8
  # Each removal saves a snapshot of the worktree first; `gitflash undo ID` brings the
9
- # directory, its branch and its uncommitted files back.
9
+ # directory, its branch and its uncommitted files back. Ignored files cannot be saved, so a
10
+ # removal that deletes some needs --confirm TOKEN instead of --yes.
10
11
  class WorktreeRemove < WorktreeCommand
11
12
  include RemovalReport
12
13
 
@@ -56,7 +57,11 @@ module Gitflash
56
57
  def remove(chosen)
57
58
  plan = build_plan(chosen)
58
59
  return planned(plan, "Would remove:\n#{preview(chosen, plan)}") if dry_run?
59
- return cancelled(plan) unless ui.confirm?(summary(chosen, plan), plan: plan)
60
+
61
+ # Ignored files are never saved, so losing them needs --confirm TOKEN, not --yes
62
+ confirmed = ui.confirm?(summary(chosen, plan), plan: plan,
63
+ irreversible: loses_ignored?(plan))
64
+ return cancelled(plan) unless confirmed
60
65
 
61
66
  outcomes = chosen.map { |worktree| remove_one(worktree) }
62
67
  report_results(plan, outcomes)
@@ -64,6 +69,10 @@ module Gitflash
64
69
 
65
70
  def dry_run? = ui.dry_run?
66
71
 
72
+ def loses_ignored?(plan)
73
+ plan[:worktrees].any? { |row| row[:ignored_count].positive? }
74
+ end
75
+
67
76
  def preview(chosen, plan) = "#{bullets(chosen)}#{ignored_warning(plan)}"
68
77
 
69
78
  # The plan reported and returned in JSON; `wt clean` adds why each worktree was chosen
@@ -79,7 +88,7 @@ module Gitflash
79
88
 
80
89
  def remove_one(worktree)
81
90
  snapshot = snapshot_for(worktree)
82
- unsaved = snapshot.skipped_files
91
+ unsaved = snapshot.unsaved_files
83
92
  return refuse_unsaved(worktree, unsaved) if unsaved.any?
84
93
 
85
94
  result = Worktrees.new(repo: repo).remove(worktree.path, force: force?)
@@ -90,8 +99,8 @@ module Gitflash
90
99
  def refuse_unsaved(worktree, files)
91
100
  shown = files.first(3).join(', ')
92
101
  shown += ", and #{files.size - 3} more" if files.size > 3
93
- message = "#{files.size} untracked file(s) over 50 MB cannot be saved by a snapshot " \
94
- "(#{shown}); move or delete them first"
102
+ message = "#{files.size} untracked file(s) cannot be saved by a snapshot (over 50 MB or " \
103
+ "matched by snapshot_exclude: #{shown}); move or delete them first"
95
104
  Removal.new(worktree: worktree, result: Result.new(success: false, output: message),
96
105
  snapshot: nil)
97
106
  end
@@ -4,31 +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
+ Config = Data.define(:protected_patterns, :stale_days, :worktree_dir, :worktree_dir_from_repo,
12
+ :snapshot_exclude) do
11
13
  # `root` is the main checkout; `home` is where the user file lives. The repository file wins
12
14
  # key by key, except `protected`: both files add patterns, so a repository can protect more
13
- # branches but never remove what the user protects.
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.
14
17
  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'])
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))
20
23
  end
21
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
+
22
41
  def self.defaults
23
- 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: [])
24
44
  end
25
45
 
26
- def self.read(path)
46
+ def self.read(path, repository: false)
27
47
  return {} unless File.file?(path)
28
48
 
29
49
  reject_duplicate_keys!(path)
30
50
  data = YAML.safe_load_file(path) || {}
31
51
  validate!(data, path)
52
+ check_repository_dir!(data['worktree_dir'], path) if repository
32
53
  data
33
54
  rescue Psych::Exception, SystemCallError, IOError => e
34
55
  raise invalid(path, "cannot be read (#{e.message.lines.first.strip})")
@@ -77,14 +98,41 @@ module Gitflash
77
98
  end
78
99
  private_class_method :check_types!
79
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
+
80
111
  def self.invalid(path, problem)
81
112
  UsageError.new("#{path} #{problem}", code: 'invalid_config')
82
113
  end
83
114
  private_class_method :invalid
84
115
 
85
- # 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}`)
86
118
  def protected?(name)
87
- 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]
88
136
  end
89
137
 
90
138
  # Directory for a new worktree. `%<repo>s` and `%<branch>s` are replaced as plain text; a
@@ -92,7 +140,9 @@ module Gitflash
92
140
  # folders.
93
141
  def worktree_path(root:, branch:)
94
142
  path = worktree_dir.gsub('%<repo>s') { File.basename(root) }.gsub('%<branch>s') { branch }
95
- File.expand_path(path, root)
143
+ path = File.expand_path(path, root)
144
+ ConfigKeys.check_contained!(path, root) if worktree_dir_from_repo
145
+ path
96
146
  end
97
147
  end
98
148
  end
@@ -7,18 +7,51 @@ 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
12
13
  PLACEHOLDERS = ['%<repo>s', '%<branch>s'].freeze
13
14
  MAX_TEMPLATE_LENGTH = 512
14
15
  CHECKS = {
15
- 'protected' => [->(v) { v.is_a?(Array) && v.all?(String) },
16
- '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 :)'],
17
18
  'stale_days' => [->(v) { v.is_a?(Integer) && v.positive? }, 'a positive whole number'],
18
19
  'worktree_dir' => [->(v) { template?(v) },
19
- 'a path of up to 512 characters that uses only %<repo>s and %<branch>s']
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']
20
23
  }.freeze
21
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
+
22
55
  # A path template: the two placeholders are the only `%` directives, so a width such as
23
56
  # `%<repo>999999999s` can never make Ruby build a huge string
24
57
  def self.template?(value)
@@ -27,5 +60,20 @@ module Gitflash
27
60
 
28
61
  !PLACEHOLDERS.reduce(value) { |rest, placeholder| rest.gsub(placeholder, '') }.include?('%')
29
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
30
78
  end
31
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
@@ -41,9 +41,11 @@ module Gitflash
41
41
  return nil if targets.empty?
42
42
  return deny(targets) if @mode == 'deny'
43
43
 
44
- saved = merge_by_dir(targets).filter_map { |target| save(target) }
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)
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))
47
49
  end
48
50
 
49
51
  # Recording is a side feature: when it fails, snapshots and the ask or deny decision still run
@@ -84,15 +86,11 @@ module Gitflash
84
86
 
85
87
  # One snapshot per directory, covering every command run there
86
88
  def merge_by_dir(targets)
87
- targets.group_by(&:dir).map do |dir, group|
88
- CommandParser::Target.new(
89
- dir: dir, command: group.map(&:command).join(' && '),
90
- scope: group.flat_map(&:scope).uniq, branches: group.flat_map(&:branches).uniq
91
- )
92
- end
89
+ targets.group_by(&:dir).map { |dir, group| CommandParser::Target.merge(dir, group) }
93
90
  end
94
91
 
95
- # [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.
96
94
  def save(target)
97
95
  return nil unless Dir.exist?(target.dir)
98
96
 
@@ -103,6 +101,9 @@ module Gitflash
103
101
  scope: target.scope, branches: branches(target))
104
102
  [target, snapshot]
105
103
  end
104
+ rescue StandardError => e
105
+ warn "gitflash hook: could not save a snapshot in #{target.dir} (#{e.message})"
106
+ nil
106
107
  end
107
108
 
108
109
  # Full ref shortened by removing refs/heads/ only; `--short` changes when a tag has the name
@@ -110,14 +111,18 @@ module Gitflash
110
111
  git('symbolic-ref', '--quiet', 'HEAD')&.delete_prefix(Repo::HEADS)
111
112
  end
112
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`
113
116
  def branches(target)
117
+ return nil if target.branches.empty?
118
+
114
119
  target.branches.filter_map do |name|
115
120
  name == :current ? current_branch : name
116
121
  end
117
122
  end
118
123
 
119
- def output(saved, targets)
120
- context = saved.empty? ? Messages.unsaved(targets) : Messages.saved(saved)
124
+ def output(saved, unsaved)
125
+ context = Messages.context(saved, unsaved)
121
126
  specific = { hookEventName: EVENT, additionalContext: context }
122
127
  if @mode == 'ask'
123
128
  specific[:permissionDecision] = 'ask'
@@ -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,16 +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
33
  SHELLS = %w[bash sh zsh dash ksh].freeze
21
34
  MAX_NESTING = 3
22
- OPTIONS_WITH_VALUE = %w[-c --git-dir --work-tree --namespace --exec-path --config-env].freeze
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
40
+
41
+ DIR_COMMANDS = %w[cd pushd popd].freeze
23
42
 
24
- def initialize(cwd)
43
+ # `aliases` returns the git aliases configured for a directory, as { name => value }
44
+ def initialize(cwd, aliases: GitInvocation.method(:configured_aliases))
25
45
  @cwd = cwd
46
+ @aliases = aliases
47
+ @alias_cache = {}
48
+ @invocation = GitInvocation.new(dir_after: method(:change_dir),
49
+ aliases: method(:aliases_in))
26
50
  end
27
51
 
28
52
  # Git commands that can lose work, with what to save before them
@@ -31,8 +55,10 @@ module Gitflash
31
55
  rule = Rules.for(git.subcommand, git.args, git.dir)
32
56
  next unless rule
33
57
 
34
- Target.new(dir: rule.fetch(:dir, git.dir), command: git.command, scope: rule[:scope],
35
- 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))
36
62
  end
37
63
  end
38
64
 
@@ -51,88 +77,85 @@ module Gitflash
51
77
  GitCommand = Data.define(:dir, :command, :subcommand, :args)
52
78
 
53
79
  def git_commands(command_line, dir = @cwd, depth = 0)
54
- segments(command_line).flat_map do |segment|
55
- words = words(segment)
56
- next [] if words.empty?
57
-
58
- if words.first == 'cd'
59
- dir = File.expand_path(words[1] || Dir.home, dir)
60
- next []
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)
61
86
  end
62
- commands_in(words, dir, segment.strip, depth)
87
+ substituted(segment, dir, depth) + in_segment(words, dir, segment.strip, depth)
63
88
  end
64
89
  end
65
90
 
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'
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'
69
95
 
70
- script = nested_script(words)
71
- script && depth < MAX_NESTING ? git_commands(script, dir, depth + 1) : []
96
+ stack.push(dir) if words.first == 'pushd'
97
+ change_dir(words[1] || Dir.home, dir)
72
98
  end
73
99
 
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))
100
+ def in_segment(words, dir, segment, depth)
101
+ return [] if words.words.empty?
78
102
 
79
- index = words.index { |word| word.match?(/\A-[a-z]*c[a-z]*\z/) }
80
- index && words[index + 1]
103
+ dir = change_dir(words.chdir, dir) if words.chdir
104
+ commands_in(words.words, dir, segment, depth, words.env)
81
105
  end
82
106
 
83
- # Splits on unquoted ; & | and newlines
84
- def segments(line)
85
- parts = [+'']
86
- quote = nil
87
- line.each_char do |char|
88
- if quote
89
- quote = nil if char == quote
90
- elsif ["'", '"'].include?(char)
91
- quote = char
92
- elsif ";&|\n".include?(char)
93
- parts << +''
94
- next
95
- end
96
- parts.last << char
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)
97
111
  end
98
- parts.reject { |part| part.strip.empty? }
99
112
  end
100
113
 
101
- # Shell words without subshell brackets, environment assignments and wrappers like sudo
102
- def words(segment)
103
- words = split(segment).map { |word| unwrap(word) }.reject(&:empty?)
104
- words.shift while words.first && prefix?(words.first)
105
- 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: [])]
106
121
  end
107
122
 
108
- def prefix?(word)
109
- 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) : []
110
132
  end
111
133
 
112
- def split(segment)
113
- Shellwords.split(segment)
114
- rescue ArgumentError
115
- 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]
116
141
  end
117
142
 
118
- def unwrap(word)
119
- 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)
120
148
  end
121
149
 
122
- def git_command(words, dir, command)
123
- index, dir = skip_git_options(words, dir)
124
- GitCommand.new(dir: dir, command: command, subcommand: words[index],
125
- 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)]
126
155
  end
127
156
 
128
- # Index of the subcommand, and the directory after any -C options
129
- def skip_git_options(words, dir)
130
- index = 1
131
- while (option = words[index])&.start_with?('-')
132
- dir = File.expand_path(words[index + 1].to_s, dir) if option == '-C'
133
- index += option == '-C' || OPTIONS_WITH_VALUE.include?(option) ? 2 : 1
134
- end
135
- [index, dir]
157
+ def aliases_in(dir)
158
+ @alias_cache[dir] ||= @aliases.call(dir)
136
159
  end
137
160
  end
138
161
  end