gitflash 0.5.1 → 0.7.0.beta1

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/README.md +69 -2
  4. data/bin/gitflash +10 -0
  5. data/command_descriptions.yml +99 -0
  6. data/lib/gitflash/branch.rb +14 -2
  7. data/lib/gitflash/cherry_pick.rb +128 -0
  8. data/lib/gitflash/cleanup.rb +70 -0
  9. data/lib/gitflash/cli.rb +72 -21
  10. data/lib/gitflash/command_runner.rb +23 -0
  11. data/lib/gitflash/commands/base.rb +15 -0
  12. data/lib/gitflash/commands/branches.rb +1 -0
  13. data/lib/gitflash/commands/checkout.rb +2 -1
  14. data/lib/gitflash/commands/clean.rb +51 -0
  15. data/lib/gitflash/commands/clean_criteria.rb +29 -0
  16. data/lib/gitflash/commands/delete.rb +23 -8
  17. data/lib/gitflash/commands/gc.rb +41 -0
  18. data/lib/gitflash/commands/hook_install.rb +77 -0
  19. data/lib/gitflash/commands/hook_status.rb +80 -0
  20. data/lib/gitflash/commands/mark.rb +85 -0
  21. data/lib/gitflash/commands/pick.rb +131 -0
  22. data/lib/gitflash/commands/pick_control.rb +67 -0
  23. data/lib/gitflash/commands/pick_outcome.rb +52 -0
  24. data/lib/gitflash/commands/removal_report.rb +65 -0
  25. data/lib/gitflash/commands/reset.rb +7 -1
  26. data/lib/gitflash/commands/snapshot_create.rb +30 -0
  27. data/lib/gitflash/commands/snapshot_list.rb +20 -0
  28. data/lib/gitflash/commands/undo.rb +131 -0
  29. data/lib/gitflash/commands/worktree_add.rb +98 -0
  30. data/lib/gitflash/commands/worktree_clean.rb +49 -0
  31. data/lib/gitflash/commands/worktree_command.rb +28 -0
  32. data/lib/gitflash/commands/worktree_list.rb +48 -0
  33. data/lib/gitflash/commands/worktree_lock.rb +54 -0
  34. data/lib/gitflash/commands/worktree_move.rb +47 -0
  35. data/lib/gitflash/commands/worktree_prune.rb +41 -0
  36. data/lib/gitflash/commands/worktree_remove.rb +119 -0
  37. data/lib/gitflash/commands/worktree_unlock.rb +12 -0
  38. data/lib/gitflash/config.rb +95 -0
  39. data/lib/gitflash/config_keys.rb +19 -0
  40. data/lib/gitflash/git/bash_command.rb +5 -4
  41. data/lib/gitflash/git/in_directory.rb +29 -0
  42. data/lib/gitflash/hook/branch_marker.rb +52 -0
  43. data/lib/gitflash/hook/claude.rb +126 -0
  44. data/lib/gitflash/hook/command_parser.rb +121 -0
  45. data/lib/gitflash/hook/creations.rb +85 -0
  46. data/lib/gitflash/hook/rules.rb +108 -0
  47. data/lib/gitflash/hook/settings.rb +73 -0
  48. data/lib/gitflash/hook_cli.rb +77 -0
  49. data/lib/gitflash/ownership.rb +59 -0
  50. data/lib/gitflash/protection.rb +19 -0
  51. data/lib/gitflash/repo.rb +12 -3
  52. data/lib/gitflash/restore.rb +141 -0
  53. data/lib/gitflash/snapshot.rb +47 -0
  54. data/lib/gitflash/snapshots.rb +70 -0
  55. data/lib/gitflash/state_capture.rb +129 -0
  56. data/lib/gitflash/ui.rb +7 -5
  57. data/lib/gitflash/version.rb +1 -1
  58. data/lib/gitflash/worktree.rb +32 -0
  59. data/lib/gitflash/worktree_cli.rb +115 -0
  60. data/lib/gitflash/worktree_revival.rb +55 -0
  61. data/lib/gitflash/worktrees.rb +147 -0
  62. data/schema/v1.json +1885 -102
  63. metadata +46 -1
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Forgets worktrees whose directory is gone (locked ones stay). Branches are not touched.
6
+ # Takes no snapshot: only git's own bookkeeping for the missing directory is removed.
7
+ class WorktreePrune < WorktreeCommand
8
+ def call
9
+ stale = worktrees.list.select { |worktree| worktree.missing? && !worktree.locked? }
10
+ return noop if stale.empty?
11
+
12
+ prune_or_ask({ worktrees: stale.map(&:path) })
13
+ end
14
+
15
+ private
16
+
17
+ def prune_or_ask(plan)
18
+ return planned(plan, "Would prune:\n#{bullets(plan)}") if ui.dry_run?
19
+
20
+ question = "You are about to prune:\n\n#{bullets(plan)}"
21
+ ui.confirm?(question, plan: plan) ? prune(plan) : cancelled(plan)
22
+ end
23
+
24
+ def prune(plan)
25
+ result = worktrees.prune
26
+ git_error!('worktree prune', result) unless result.success?
27
+
28
+ ui.report(status: 'done', plan: plan, result: { pruned: plan[:worktrees] },
29
+ text: "Pruned:\n#{bullets(plan)}")
30
+ end
31
+
32
+ def noop
33
+ ui.report(status: 'noop', result: { pruned: [] }, text: 'No missing worktrees to prune')
34
+ end
35
+
36
+ def bullets(plan)
37
+ plan[:worktrees].map { |path| "* #{path}" }.join("\n")
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Removes worktrees given as paths or branch names, or picked from a menu.
6
+ # The main checkout, the worktree gitflash runs in, locked and dirty worktrees are refused
7
+ # unless --force is given (the main checkout and the current one never can be removed).
8
+ # Each removal saves a snapshot of the worktree first; `gitflash undo ID` brings the
9
+ # directory, its branch and its uncommitted files back.
10
+ class WorktreeRemove < WorktreeCommand
11
+ include RemovalReport
12
+
13
+ def call(*targets)
14
+ worktrees = Worktrees.new(repo: repo).list
15
+ targets = pick(worktrees) if targets.empty?
16
+ return 0 if targets.nil?
17
+
18
+ chosen = resolve(targets, worktrees)
19
+ remove(chosen)
20
+ end
21
+
22
+ private
23
+
24
+ def pick(worktrees)
25
+ require_interactive!('Pass the worktrees to remove: gitflash wt remove PATH_OR_BRANCH...')
26
+ choices = worktrees.reject { |worktree| worktree.main? || current?(worktree) }
27
+ .to_h { |wt| ["#{wt.branch || '(detached)'} #{wt.path}", wt.path] }
28
+ return report_nothing('No worktrees available to remove') if choices.empty?
29
+
30
+ picked = ui.multi_select('Select worktrees to remove', choices)
31
+ picked.empty? ? report_nothing('No worktrees selected') : picked
32
+ end
33
+
34
+ def force?
35
+ options[:force] ? true : false
36
+ end
37
+
38
+ def current?(worktree)
39
+ worktree.path == repo.toplevel
40
+ end
41
+
42
+ def resolve(targets, worktrees)
43
+ chosen = targets.uniq.map { |target| find!(target, worktrees) }.uniq(&:path)
44
+ refused = chosen.filter_map do |worktree|
45
+ worktree.removal_blocker(force: force?, current: current?(worktree))
46
+ end
47
+ usage_error!('protected_worktree', refused.join('; ')) if refused.any?
48
+ chosen
49
+ end
50
+
51
+ def remove(chosen)
52
+ plan = build_plan(chosen)
53
+ return planned(plan, "Would remove:\n#{preview(chosen, plan)}") if dry_run?
54
+ return cancelled(plan) unless ui.confirm?(summary(chosen, plan), plan: plan)
55
+
56
+ outcomes = chosen.map { |worktree| remove_one(worktree) }
57
+ report_results(plan, outcomes)
58
+ end
59
+
60
+ def dry_run? = ui.dry_run?
61
+
62
+ def preview(chosen, plan) = "#{bullets(chosen)}#{ignored_warning(plan)}"
63
+
64
+ # The plan reported and returned in JSON; `wt clean` adds why each worktree was chosen
65
+ def build_plan(chosen)
66
+ { worktrees: chosen.map { |worktree| plan_row(worktree) }, force: force? }
67
+ end
68
+
69
+ def plan_row(worktree)
70
+ ignored = worktree.missing? ? [] : Worktrees.new(repo: repo).ignored(worktree.path)
71
+ { path: worktree.path, branch: worktree.branch, dirty: worktree.dirty?,
72
+ locked: worktree.locked?, ignored_count: ignored.size, ignored: ignored.first(5) }
73
+ end
74
+
75
+ def remove_one(worktree)
76
+ snapshot = snapshot_for(worktree)
77
+ unsaved = snapshot.skipped_files
78
+ return refuse_unsaved(worktree, unsaved) if unsaved.any?
79
+
80
+ result = Worktrees.new(repo: repo).remove(worktree.path, force: force?)
81
+ Removal.new(worktree: worktree, result: result, snapshot: (snapshot if result.success?))
82
+ end
83
+
84
+ # Files a snapshot cannot hold (untracked and over 50 MB) would be lost for good
85
+ def refuse_unsaved(worktree, files)
86
+ shown = files.first(3).join(', ')
87
+ shown += ", and #{files.size - 3} more" if files.size > 3
88
+ message = "#{files.size} untracked file(s) over 50 MB cannot be saved by a snapshot " \
89
+ "(#{shown}); move or delete them first"
90
+ Removal.new(worktree: worktree, result: Result.new(success: false, output: message),
91
+ snapshot: nil)
92
+ end
93
+
94
+ # A missing directory has no HEAD or files to save; only the branch tip is
95
+ def snapshot_for(worktree)
96
+ reason = "gitflash wt remove #{worktree.path}"
97
+ branches = [worktree.branch].compact
98
+ return take_snapshot(reason, scope: %w[branches], branches: branches) if worktree.missing?
99
+
100
+ take_snapshot(reason, scope: %w[branches head worktree], branches: branches,
101
+ dir: worktree.path)
102
+ end
103
+
104
+ def summary(chosen, plan)
105
+ "You are about to remove the following worktrees:\n\n#{bullets(chosen)}" \
106
+ "#{ignored_warning(plan)}"
107
+ end
108
+
109
+ def bullets(chosen)
110
+ chosen.map { |worktree| "* #{worktree.path} (#{worktree.branch || 'detached'})" }.join("\n")
111
+ end
112
+
113
+ def report_nothing(text)
114
+ ui.report(status: 'noop', result: { removed: [], failed: [] }, text: text)
115
+ nil
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Commands
5
+ # Unlocks a worktree; see WorktreeLock
6
+ class WorktreeUnlock < WorktreeLock
7
+ private
8
+
9
+ def lock? = false
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,95 @@
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
12
+ 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
+ end
19
+
20
+ def self.defaults
21
+ new(protected_patterns: [], stale_days: 30, worktree_dir: ConfigKeys::DEFAULT_WORKTREE_DIR)
22
+ end
23
+
24
+ def self.read(path)
25
+ return {} unless File.file?(path)
26
+
27
+ reject_duplicate_keys!(path)
28
+ data = YAML.safe_load_file(path) || {}
29
+ validate!(data, path)
30
+ data
31
+ rescue Psych::Exception, SystemCallError, IOError => e
32
+ raise invalid(path, "cannot be read (#{e.message.lines.first.strip})")
33
+ end
34
+ private_class_method :read
35
+
36
+ # A YAML loader keeps only the last of two equal keys, which could drop a `protected` list
37
+ def self.reject_duplicate_keys!(path)
38
+ root = Psych.parse_file(path)
39
+ return unless root
40
+
41
+ duplicate = duplicate_key(root)
42
+ raise invalid(path, "has the key '#{duplicate}' more than once") if duplicate
43
+ end
44
+ private_class_method :reject_duplicate_keys!
45
+
46
+ def self.duplicate_key(node)
47
+ own = node.is_a?(Psych::Nodes::Mapping) ? own_duplicate(node) : nil
48
+ own || (node.children || []).filter_map { |child| duplicate_key(child) }.first
49
+ end
50
+ private_class_method :duplicate_key
51
+
52
+ def self.own_duplicate(mapping)
53
+ keys = mapping.children.each_slice(2).filter_map do |key, _value|
54
+ key.value if key.respond_to?(:value)
55
+ end
56
+ keys.tally.find { |_key, count| count > 1 }&.first
57
+ end
58
+ private_class_method :own_duplicate
59
+
60
+ def self.validate!(data, path)
61
+ raise invalid(path, 'must be a mapping') unless data.is_a?(Hash)
62
+
63
+ unknown = data.keys - ConfigKeys::DEFAULTS.keys
64
+ raise invalid(path, "has unknown keys: #{unknown.join(', ')}") if unknown.any?
65
+
66
+ check_types!(data, path)
67
+ end
68
+ private_class_method :validate!
69
+
70
+ def self.check_types!(data, path)
71
+ data.each do |key, value|
72
+ check, expected = ConfigKeys::CHECKS.fetch(key)
73
+ raise invalid(path, "'#{key}' must be #{expected}") unless check.call(value)
74
+ end
75
+ end
76
+ private_class_method :check_types!
77
+
78
+ def self.invalid(path, problem)
79
+ UsageError.new("#{path} #{problem}", code: 'invalid_config')
80
+ end
81
+ private_class_method :invalid
82
+
83
+ # True when the branch name matches a configured pattern (shell-style `*` and `?`)
84
+ def protected?(name)
85
+ protected_patterns.any? { |pattern| File.fnmatch?(pattern, name) }
86
+ end
87
+
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.
90
+ def worktree_path(root:, branch:)
91
+ path = format(worktree_dir, repo: File.basename(root), branch: branch)
92
+ File.expand_path(path, root)
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,19 @@
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
+ CHECKS = {
13
+ 'protected' => [->(v) { v.is_a?(Array) && v.all?(String) },
14
+ 'a list of branch name patterns'],
15
+ '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']
17
+ }.freeze
18
+ end
19
+ end
@@ -8,16 +8,17 @@ module Gitflash
8
8
  class << self
9
9
  # Runs a command without a shell and returns its stdout.
10
10
  # Raises CommandError when the command exits with a non-zero status.
11
- def exec(*args)
12
- stdout, stderr, success = capture(*args)
11
+ def exec(*args, env: {})
12
+ stdout, stderr, success = capture(*args, env: env)
13
13
  raise CommandError, "#{args.join(' ')} failed: #{stderr.strip}" unless success
14
14
 
15
15
  stdout
16
16
  end
17
17
 
18
18
  # Runs a command without a shell and returns [stdout, stderr, success].
19
- def capture(*args)
20
- stdout, stderr, status = Open3.capture3(*args)
19
+ # `env` adds environment variables for this command only.
20
+ def capture(*args, env: {})
21
+ stdout, stderr, status = Open3.capture3(env, *args)
21
22
  [stdout, stderr, status.success?]
22
23
  rescue SystemCallError => e
23
24
  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,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Hook
5
+ # Marks the branches an agent just created as agent work (see Ownership), so
6
+ # `gitflash clean --agent` can find them. Runs in the PostToolUse hook, after the command:
7
+ # the branch exists then, and Claude Code only runs it when the command succeeded.
8
+ #
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.
11
+ class BranchMarker
12
+ CREATED = 'branch: Created from'
13
+ RECENT_SECONDS = 120
14
+
15
+ def initialize(clock: -> { Time.now })
16
+ @clock = clock
17
+ end
18
+
19
+ # Branch names marked as agent work, in the directories the command ran in
20
+ def call(command, cwd)
21
+ created = CommandParser.new(cwd).creations(command)
22
+ created.select { |creation| mark(creation[:dir], creation[:branch]) }
23
+ .map { |creation| creation[:branch] }.uniq
24
+ end
25
+
26
+ private
27
+
28
+ def mark(dir, branch)
29
+ return false unless Dir.exist?(dir)
30
+
31
+ Dir.chdir(dir) do
32
+ ownership = Ownership.new
33
+ next false unless created_recently?(branch) && !ownership.all.key?(branch)
34
+
35
+ ownership.mark(branch, 'agent')
36
+ true
37
+ end
38
+ end
39
+
40
+ # The oldest reflog entry of the branch is its creation, and it is recent
41
+ def created_recently?(branch)
42
+ stdout, _stderr, success = Git::BashCommand.capture(
43
+ 'git', 'log', '-g', '--format=%ct%x1f%gs', "#{Repo::HEADS}#{branch}", '--'
44
+ )
45
+ return false unless success
46
+
47
+ created, subject = stdout.lines(chomp: true).last.to_s.split("\x1f", 2)
48
+ subject.to_s.start_with?(CREATED) && @clock.call.to_i - created.to_i <= RECENT_SECONDS
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Gitflash
6
+ module Hook
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.
9
+ #
10
+ # Reads the hook input (JSON on stdin) and, when the command can lose work git cannot
11
+ # restore, acts according to the mode:
12
+ # snapshot save a snapshot and let the command run (default)
13
+ # ask save a snapshot and ask the user to approve the command
14
+ # deny block the command and point to the gitflash equivalent
15
+ # Returns the hook output as a Hash, or nil to leave the decision to Claude Code.
16
+ class Claude
17
+ MODES = %w[snapshot ask deny].freeze
18
+ EVENT = 'PreToolUse'
19
+ POST_EVENT = 'PostToolUse'
20
+
21
+ def initialize(mode: 'snapshot', snapshots: -> { Gitflash::Snapshots.new },
22
+ marker: BranchMarker.new)
23
+ @mode = mode
24
+ @snapshots = snapshots
25
+ @marker = marker
26
+ end
27
+
28
+ def call(input)
29
+ data = JSON.parse(input)
30
+ return after_command(data) if data['hook_event_name'] == POST_EVENT
31
+
32
+ targets = targets(data)
33
+ return nil if targets.empty?
34
+ return deny(targets) if @mode == 'deny'
35
+
36
+ saved = merge_by_dir(targets).filter_map { |target| save(target) }
37
+ saved.empty? ? nil : output(saved)
38
+ end
39
+
40
+ private
41
+
42
+ # PostToolUse: mark the branches the command created, and tell Claude
43
+ def after_command(data)
44
+ return nil unless data['tool_name'] == 'Bash'
45
+
46
+ marked = @marker.call(data.dig('tool_input', 'command').to_s, data['cwd'] || Dir.pwd)
47
+ return nil if marked.empty?
48
+
49
+ context = "gitflash marked #{marked.join(', ')} as agent work, so " \
50
+ '`gitflash clean --agent` can find it. Change it with ' \
51
+ '`gitflash mark BRANCH --owner human`.'
52
+ { hookSpecificOutput: { hookEventName: POST_EVENT, additionalContext: context } }
53
+ end
54
+
55
+ def targets(data)
56
+ return [] unless data['tool_name'] == 'Bash'
57
+
58
+ command = data.dig('tool_input', 'command').to_s
59
+ CommandParser.new(data['cwd'] || Dir.pwd).targets(command)
60
+ end
61
+
62
+ # One snapshot per directory, covering every command run there
63
+ 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
70
+ end
71
+
72
+ # [target, snapshot], or nil when the directory is not a git work tree
73
+ def save(target)
74
+ return nil unless Dir.exist?(target.dir)
75
+
76
+ Dir.chdir(target.dir) do
77
+ next nil unless git('rev-parse', '--is-inside-work-tree') == 'true'
78
+
79
+ snapshot = @snapshots.call.create(reason: "agent hook: before `#{target.command}`",
80
+ scope: target.scope, branches: branches(target))
81
+ [target, snapshot]
82
+ end
83
+ end
84
+
85
+ # Full ref shortened by removing refs/heads/ only; `--short` changes when a tag has the name
86
+ def current_branch
87
+ git('symbolic-ref', '--quiet', 'HEAD')&.delete_prefix(Repo::HEADS)
88
+ end
89
+
90
+ def branches(target)
91
+ target.branches.filter_map do |name|
92
+ name == :current ? current_branch : name
93
+ end
94
+ end
95
+
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`).'
101
+ specific = { hookEventName: EVENT, additionalContext: context }
102
+ if @mode == 'ask'
103
+ specific[:permissionDecision] = 'ask'
104
+ specific[:permissionDecisionReason] =
105
+ "This command can discard work git cannot restore. #{context}"
106
+ end
107
+ { hookSpecificOutput: specific }
108
+ end
109
+
110
+ 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
+ { hookSpecificOutput: { hookEventName: EVENT, permissionDecision: 'deny',
116
+ permissionDecisionReason: reason } }
117
+ end
118
+
119
+ # Stripped stdout of a git command, or nil when it fails
120
+ def git(*)
121
+ stdout, _stderr, success = Git::BashCommand.capture('git', *)
122
+ success ? stdout.strip : nil
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+
5
+ module Gitflash
6
+ module Hook
7
+ # Finds git commands in a shell command line that can lose work git itself cannot restore:
8
+ # uncommitted or untracked files, deleted branches (their reflog is deleted with them) and
9
+ # dropped stashes. Commands that only add history (commit, merge, pull) are ignored because
10
+ # the reflog already covers them. See Rules for the list.
11
+ #
12
+ # The parser is deliberately simple: it splits on ; & | and newlines outside quotes, follows
13
+ # `cd DIR` and `git -C DIR`, and may report a command more often than needed. An extra
14
+ # snapshot is harmless; a missed one is not.
15
+ 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)
18
+
19
+ PREFIXES = %w[sudo command exec time nohup env].freeze
20
+ OPTIONS_WITH_VALUE = %w[-c --git-dir --work-tree --namespace --exec-path --config-env].freeze
21
+
22
+ def initialize(cwd)
23
+ @cwd = cwd
24
+ end
25
+
26
+ # Git commands that can lose work, with what to save before them
27
+ def targets(command_line)
28
+ git_commands(command_line).filter_map do |git|
29
+ rule = Rules.for(git.subcommand, git.args, git.dir)
30
+ next unless rule
31
+
32
+ Target.new(dir: rule.fetch(:dir, git.dir), command: git.command, scope: rule[:scope],
33
+ branches: rule[:branches] || [])
34
+ end
35
+ end
36
+
37
+ # Branches that git commands in the line create, as { dir:, branch: }
38
+ def creations(command_line)
39
+ git_commands(command_line).filter_map do |git|
40
+ branch = Creations.for(git.subcommand, git.args)
41
+ { dir: git.dir, branch: branch } if branch
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ # A git command found in the line: the directory it runs in (after `cd` and `-C`), the
48
+ # subcommand and its arguments
49
+ GitCommand = Data.define(:dir, :command, :subcommand, :args)
50
+
51
+ def git_commands(command_line)
52
+ dir = @cwd
53
+ segments(command_line).filter_map do |segment|
54
+ words = words(segment)
55
+ next if words.empty?
56
+
57
+ if words.first == 'cd'
58
+ dir = File.expand_path(words[1] || Dir.home, dir)
59
+ next
60
+ end
61
+ git_command(words, dir, segment.strip) if File.basename(words.first) == 'git'
62
+ end
63
+ end
64
+
65
+ # Splits on unquoted ; & | and newlines
66
+ def segments(line)
67
+ parts = [+'']
68
+ quote = nil
69
+ line.each_char do |char|
70
+ if quote
71
+ quote = nil if char == quote
72
+ elsif ["'", '"'].include?(char)
73
+ quote = char
74
+ elsif ";&|\n".include?(char)
75
+ parts << +''
76
+ next
77
+ end
78
+ parts.last << char
79
+ end
80
+ parts.reject { |part| part.strip.empty? }
81
+ end
82
+
83
+ # Shell words without subshell brackets, environment assignments and wrappers like sudo
84
+ def words(segment)
85
+ words = split(segment).map { |word| unwrap(word) }.reject(&:empty?)
86
+ words.shift while words.first && prefix?(words.first)
87
+ words
88
+ end
89
+
90
+ def prefix?(word)
91
+ PREFIXES.include?(word) || word.match?(/\A\w+=/)
92
+ end
93
+
94
+ def split(segment)
95
+ Shellwords.split(segment)
96
+ rescue ArgumentError
97
+ segment.split
98
+ end
99
+
100
+ def unwrap(word)
101
+ word.delete_prefix('(').delete_prefix('{').delete_suffix(')').delete_suffix('}')
102
+ end
103
+
104
+ def git_command(words, dir, command)
105
+ index, dir = skip_git_options(words, dir)
106
+ GitCommand.new(dir: dir, command: command, subcommand: words[index],
107
+ args: words[(index + 1)..] || [])
108
+ end
109
+
110
+ # Index of the subcommand, and the directory after any -C options
111
+ def skip_git_options(words, dir)
112
+ index = 1
113
+ while (option = words[index])&.start_with?('-')
114
+ dir = File.expand_path(words[index + 1].to_s, dir) if option == '-C'
115
+ index += option == '-C' || OPTIONS_WITH_VALUE.include?(option) ? 2 : 1
116
+ end
117
+ [index, dir]
118
+ end
119
+ end
120
+ end
121
+ end