gitflash 0.5.1 → 0.6.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +44 -1
- data/bin/gitflash +10 -0
- data/command_descriptions.yml +23 -0
- data/lib/gitflash/branch.rb +7 -0
- data/lib/gitflash/cli.rb +35 -21
- data/lib/gitflash/command_runner.rb +23 -0
- data/lib/gitflash/commands/base.rb +9 -0
- data/lib/gitflash/commands/checkout.rb +2 -1
- data/lib/gitflash/commands/delete.rb +6 -3
- data/lib/gitflash/commands/gc.rb +41 -0
- data/lib/gitflash/commands/hook_install.rb +69 -0
- data/lib/gitflash/commands/hook_status.rb +62 -0
- data/lib/gitflash/commands/reset.rb +7 -1
- data/lib/gitflash/commands/snapshot_create.rb +30 -0
- data/lib/gitflash/commands/snapshot_list.rb +20 -0
- data/lib/gitflash/commands/undo.rb +99 -0
- data/lib/gitflash/git/bash_command.rb +5 -4
- data/lib/gitflash/hook/claude.rb +106 -0
- data/lib/gitflash/hook/command_parser.rb +101 -0
- data/lib/gitflash/hook/rules.rb +108 -0
- data/lib/gitflash/hook/settings.rb +60 -0
- data/lib/gitflash/hook_cli.rb +69 -0
- data/lib/gitflash/repo.rb +10 -0
- data/lib/gitflash/restore.rb +140 -0
- data/lib/gitflash/snapshot.rb +47 -0
- data/lib/gitflash/snapshots.rb +70 -0
- data/lib/gitflash/state_capture.rb +129 -0
- data/lib/gitflash/ui.rb +7 -5
- data/lib/gitflash/version.rb +1 -1
- data/schema/v1.json +804 -69
- metadata +17 -1
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitflash
|
|
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.
|
|
7
|
+
class Undo < Base
|
|
8
|
+
def call(id = nil)
|
|
9
|
+
snapshot = find(id)
|
|
10
|
+
return no_snapshots unless snapshot
|
|
11
|
+
|
|
12
|
+
check_worktree!(snapshot)
|
|
13
|
+
restore = Restore.new(snapshot)
|
|
14
|
+
plan = restore.plan.merge(snapshot: snapshot.id)
|
|
15
|
+
return nothing_to_undo(plan) if restore.empty?
|
|
16
|
+
|
|
17
|
+
restore!(snapshot, restore, plan)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def find(id)
|
|
23
|
+
return snapshots.list.first if id.nil?
|
|
24
|
+
|
|
25
|
+
snapshots.find(id) || usage_error!('unknown_snapshot', "Unknown snapshot '#{id}'")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Branches are shared between worktrees; HEAD and files belong to one worktree
|
|
29
|
+
def check_worktree!(snapshot)
|
|
30
|
+
return unless snapshot.scope?('head') || snapshot.scope?('worktree')
|
|
31
|
+
return if snapshot.worktree_path == repo.toplevel
|
|
32
|
+
|
|
33
|
+
usage_error!('wrong_worktree', "Snapshot #{snapshot.id} was taken in " \
|
|
34
|
+
"#{snapshot.worktree_path}; run gitflash undo there")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def restore!(snapshot, restore, plan)
|
|
38
|
+
text = "snapshot #{snapshot.id}:\n#{describe(plan)}"
|
|
39
|
+
return planned(plan, "Would restore #{text}") if ui.dry_run?
|
|
40
|
+
return cancelled(plan) unless ui.confirm?(summary(snapshot, plan), plan: plan)
|
|
41
|
+
|
|
42
|
+
apply(snapshot, restore, plan)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def apply(snapshot, restore, plan)
|
|
46
|
+
before = snapshots.create(reason: "before undo #{snapshot.id}", scope: snapshot.scope,
|
|
47
|
+
branches: snapshot.branches.keys)
|
|
48
|
+
failure = restore.apply
|
|
49
|
+
raise_failure(failure, before, plan) if failure
|
|
50
|
+
|
|
51
|
+
ui.report(status: 'done', plan: plan, result: { snapshot: snapshot.id }, undo: before,
|
|
52
|
+
text: "Restored snapshot #{snapshot.id}:\n#{describe(plan)}")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def raise_failure(failure, before, plan)
|
|
56
|
+
message = "gitflash undo failed:\n#{failure.output}\n" \
|
|
57
|
+
"The state before this undo is saved as snapshot #{before.id}."
|
|
58
|
+
raise Error.new(message, code: 'git_failed', plan: plan)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def no_snapshots
|
|
62
|
+
ui.report(status: 'noop', result: { snapshot: nil }, text: 'No snapshots to undo')
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def nothing_to_undo(plan)
|
|
66
|
+
text = "Nothing to undo: the repository already matches snapshot #{plan[:snapshot]}"
|
|
67
|
+
ui.report(status: 'noop', plan: plan, result: { snapshot: plan[:snapshot] }, text: text)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def summary(snapshot, plan)
|
|
71
|
+
"You are about to restore snapshot #{snapshot.id} " \
|
|
72
|
+
"(#{snapshot.reason}, #{snapshot.created_at}):\n#{describe(plan)}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def describe(plan)
|
|
76
|
+
lines = plan[:branches].map { |change| branch_line(change) }
|
|
77
|
+
lines << head_line(plan[:head]) if plan[:head]
|
|
78
|
+
lines << '* restore tracked, staged and untracked files' if plan[:worktree]
|
|
79
|
+
lines << "* restore #{plan[:stashes].size} stash entries" if plan[:stashes].any?
|
|
80
|
+
lines.join("\n")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def branch_line(change)
|
|
84
|
+
target = change[:to][0, 7]
|
|
85
|
+
return "* recreate #{change[:branch]} at #{target}" unless change[:from]
|
|
86
|
+
|
|
87
|
+
"* move #{change[:branch]} back to #{target}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def head_line(head)
|
|
91
|
+
"* switch HEAD from #{short(head[:from])} to #{short(head[:to])}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def short(ref)
|
|
95
|
+
ref.to_s.match?(/\A\h{40}\z/) ? ref[0, 7] : ref.to_s
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
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
|
-
|
|
20
|
-
|
|
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,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Gitflash
|
|
6
|
+
module Hook
|
|
7
|
+
# Claude Code PreToolUse hook for the Bash tool.
|
|
8
|
+
#
|
|
9
|
+
# Reads the hook input (JSON on stdin) and, when the command can lose work git cannot
|
|
10
|
+
# restore, acts according to the mode:
|
|
11
|
+
# snapshot save a snapshot and let the command run (default)
|
|
12
|
+
# ask save a snapshot and ask the user to approve the command
|
|
13
|
+
# deny block the command and point to the gitflash equivalent
|
|
14
|
+
# Returns the hook output as a Hash, or nil to leave the decision to Claude Code.
|
|
15
|
+
class Claude
|
|
16
|
+
MODES = %w[snapshot ask deny].freeze
|
|
17
|
+
EVENT = 'PreToolUse'
|
|
18
|
+
|
|
19
|
+
def initialize(mode: 'snapshot', snapshots: -> { Gitflash::Snapshots.new })
|
|
20
|
+
@mode = mode
|
|
21
|
+
@snapshots = snapshots
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call(input)
|
|
25
|
+
targets = targets(JSON.parse(input))
|
|
26
|
+
return nil if targets.empty?
|
|
27
|
+
return deny(targets) if @mode == 'deny'
|
|
28
|
+
|
|
29
|
+
saved = merge_by_dir(targets).filter_map { |target| save(target) }
|
|
30
|
+
saved.empty? ? nil : output(saved)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def targets(data)
|
|
36
|
+
return [] unless data['tool_name'] == 'Bash'
|
|
37
|
+
|
|
38
|
+
command = data.dig('tool_input', 'command').to_s
|
|
39
|
+
CommandParser.new(data['cwd'] || Dir.pwd).targets(command)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# One snapshot per directory, covering every command run there
|
|
43
|
+
def merge_by_dir(targets)
|
|
44
|
+
targets.group_by(&:dir).map do |dir, group|
|
|
45
|
+
CommandParser::Target.new(
|
|
46
|
+
dir: dir, command: group.map(&:command).join(' && '),
|
|
47
|
+
scope: group.flat_map(&:scope).uniq, branches: group.flat_map(&:branches).uniq
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# [target, snapshot], or nil when the directory is not a git work tree
|
|
53
|
+
def save(target)
|
|
54
|
+
return nil unless Dir.exist?(target.dir)
|
|
55
|
+
|
|
56
|
+
Dir.chdir(target.dir) do
|
|
57
|
+
next nil unless git('rev-parse', '--is-inside-work-tree') == 'true'
|
|
58
|
+
|
|
59
|
+
snapshot = @snapshots.call.create(reason: "agent hook: before `#{target.command}`",
|
|
60
|
+
scope: target.scope, branches: branches(target))
|
|
61
|
+
[target, snapshot]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Full ref shortened by removing refs/heads/ only; `--short` changes when a tag has the name
|
|
66
|
+
def current_branch
|
|
67
|
+
git('symbolic-ref', '--quiet', 'HEAD')&.delete_prefix(Repo::HEADS)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def branches(target)
|
|
71
|
+
target.branches.filter_map do |name|
|
|
72
|
+
name == :current ? current_branch : name
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
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`).'
|
|
81
|
+
specific = { hookEventName: EVENT, additionalContext: context }
|
|
82
|
+
if @mode == 'ask'
|
|
83
|
+
specific[:permissionDecision] = 'ask'
|
|
84
|
+
specific[:permissionDecisionReason] =
|
|
85
|
+
"This command can discard work git cannot restore. #{context}"
|
|
86
|
+
end
|
|
87
|
+
{ hookSpecificOutput: specific }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
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
|
+
{ hookSpecificOutput: { hookEventName: EVENT, permissionDecision: 'deny',
|
|
96
|
+
permissionDecisionReason: reason } }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Stripped stdout of a git command, or nil when it fails
|
|
100
|
+
def git(*)
|
|
101
|
+
stdout, _stderr, success = Git::BashCommand.capture('git', *)
|
|
102
|
+
success ? stdout.strip : nil
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
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
|
+
def targets(command_line)
|
|
27
|
+
dir = @cwd
|
|
28
|
+
segments(command_line).filter_map do |segment|
|
|
29
|
+
words = words(segment)
|
|
30
|
+
next if words.empty?
|
|
31
|
+
|
|
32
|
+
if words.first == 'cd'
|
|
33
|
+
dir = File.expand_path(words[1] || Dir.home, dir)
|
|
34
|
+
next
|
|
35
|
+
end
|
|
36
|
+
git_target(words, dir, segment.strip) if File.basename(words.first) == 'git'
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# Splits on unquoted ; & | and newlines
|
|
43
|
+
def segments(line)
|
|
44
|
+
parts = [+'']
|
|
45
|
+
quote = nil
|
|
46
|
+
line.each_char do |char|
|
|
47
|
+
if quote
|
|
48
|
+
quote = nil if char == quote
|
|
49
|
+
elsif ["'", '"'].include?(char)
|
|
50
|
+
quote = char
|
|
51
|
+
elsif ";&|\n".include?(char)
|
|
52
|
+
parts << +''
|
|
53
|
+
next
|
|
54
|
+
end
|
|
55
|
+
parts.last << char
|
|
56
|
+
end
|
|
57
|
+
parts.reject { |part| part.strip.empty? }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Shell words without subshell brackets, environment assignments and wrappers like sudo
|
|
61
|
+
def words(segment)
|
|
62
|
+
words = split(segment).map { |word| unwrap(word) }.reject(&:empty?)
|
|
63
|
+
words.shift while words.first && prefix?(words.first)
|
|
64
|
+
words
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def prefix?(word)
|
|
68
|
+
PREFIXES.include?(word) || word.match?(/\A\w+=/)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def split(segment)
|
|
72
|
+
Shellwords.split(segment)
|
|
73
|
+
rescue ArgumentError
|
|
74
|
+
segment.split
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def unwrap(word)
|
|
78
|
+
word.delete_prefix('(').delete_prefix('{').delete_suffix(')').delete_suffix('}')
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def git_target(words, dir, command)
|
|
82
|
+
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] || [])
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Index of the subcommand, and the directory after any -C options
|
|
91
|
+
def skip_git_options(words, dir)
|
|
92
|
+
index = 1
|
|
93
|
+
while (option = words[index])&.start_with?('-')
|
|
94
|
+
dir = File.expand_path(words[index + 1].to_s, dir) if option == '-C'
|
|
95
|
+
index += option == '-C' || OPTIONS_WITH_VALUE.include?(option) ? 2 : 1
|
|
96
|
+
end
|
|
97
|
+
[index, dir]
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitflash
|
|
4
|
+
module Hook
|
|
5
|
+
# Which git subcommands can lose work git cannot restore, and what to save before them.
|
|
6
|
+
# Each rule takes the subcommand's arguments and the directory, and returns nil (safe) or
|
|
7
|
+
# { scope:, branches:, dir: } where branches may contain :current for the checked-out one.
|
|
8
|
+
module Rules
|
|
9
|
+
FULL = %w[branches head worktree].freeze
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def for(subcommand, args, dir)
|
|
14
|
+
rule = RULES[subcommand]
|
|
15
|
+
rule&.call(args, dir)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def any?(args, flags)
|
|
19
|
+
args.intersect?(flags)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# `git checkout PATH` overwrites uncommitted changes in PATH without any flag, and telling a
|
|
23
|
+
# path from a branch needs git; an argument that names an existing file counts as a path.
|
|
24
|
+
def checkout(args, dir)
|
|
25
|
+
discards = any?(args, %w[-- . --force --patch --ours --theirs]) ||
|
|
26
|
+
short_flags(args).match?(/[fp]/) || path_arg?(args, dir)
|
|
27
|
+
discards ? { scope: %w[worktree] } : nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The letters of all short options, so bundled ones such as `-fq` are seen
|
|
31
|
+
def short_flags(args)
|
|
32
|
+
args.select { |arg| arg.start_with?('-') && !arg.start_with?('--') }.join
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def path_arg?(args, dir)
|
|
36
|
+
args.reject { |arg| arg.start_with?('-') }.any? do |arg|
|
|
37
|
+
File.exist?(File.expand_path(arg, dir))
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def clean(args, _dir)
|
|
42
|
+
flags = args.select { |arg| arg.start_with?('-') }
|
|
43
|
+
short = short_flags(args)
|
|
44
|
+
force = flags.include?('--force') || short.include?('f')
|
|
45
|
+
dry_run = any?(flags, %w[--dry-run --interactive]) || short.match?(/[ni]/)
|
|
46
|
+
force && !dry_run ? { scope: %w[worktree] } : nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def switch(args, _dir)
|
|
50
|
+
discards = any?(args, %w[--force --discard-changes]) || short_flags(args).include?('f')
|
|
51
|
+
discards ? { scope: %w[head worktree] } : nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def stash(args, _dir)
|
|
55
|
+
case args.first
|
|
56
|
+
when 'drop', 'clear' then { scope: %w[stashes] }
|
|
57
|
+
when 'pop' then { scope: %w[worktree stashes] }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def branch(args, _dir)
|
|
62
|
+
return nil unless any?(args, %w[-d -D --delete -f --force -m -M --move -C --copy])
|
|
63
|
+
|
|
64
|
+
{ scope: %w[branches], branches: args.reject { |arg| arg.start_with?('-') } }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def update_ref(args, _dir)
|
|
68
|
+
return nil unless args.include?('-d')
|
|
69
|
+
|
|
70
|
+
heads = args.select { |arg| arg.start_with?('refs/heads/') }
|
|
71
|
+
{ scope: %w[branches], branches: heads.map { |ref| ref.delete_prefix('refs/heads/') } }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def rebase(args, _dir)
|
|
75
|
+
args.include?('--continue') ? nil : { scope: FULL, branches: [:current] }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def abort(args, _dir)
|
|
79
|
+
any?(args, %w[--abort --quit]) ? { scope: %w[worktree] } : nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# `git worktree remove --force PATH` deletes that worktree's uncommitted files
|
|
83
|
+
def worktree(args, dir)
|
|
84
|
+
return nil unless args.first == 'remove' && any?(args, %w[-f --force])
|
|
85
|
+
|
|
86
|
+
path = args[1..].reject { |arg| arg.start_with?('-') }.first
|
|
87
|
+
path && { scope: %w[worktree], dir: File.expand_path(path, dir) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
RULES = {
|
|
91
|
+
'reset' => ->(_args, _dir) { { scope: FULL, branches: [:current] } },
|
|
92
|
+
'checkout' => method(:checkout),
|
|
93
|
+
'restore' => ->(_args, _dir) { { scope: %w[worktree] } },
|
|
94
|
+
'clean' => method(:clean),
|
|
95
|
+
'switch' => method(:switch),
|
|
96
|
+
'stash' => method(:stash),
|
|
97
|
+
'branch' => method(:branch),
|
|
98
|
+
'update-ref' => method(:update_ref),
|
|
99
|
+
'rebase' => method(:rebase),
|
|
100
|
+
'merge' => method(:abort),
|
|
101
|
+
'cherry-pick' => method(:abort),
|
|
102
|
+
'revert' => method(:abort),
|
|
103
|
+
'am' => method(:abort),
|
|
104
|
+
'worktree' => method(:worktree)
|
|
105
|
+
}.freeze
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Gitflash
|
|
6
|
+
module Hook
|
|
7
|
+
# Where Claude Code reads the settings that can hold the gitflash hook, following its rules:
|
|
8
|
+
# local .claude/settings.local.json at the repository root (the main checkout's root
|
|
9
|
+
# inside a linked worktree), for sessions started anywhere in the repository
|
|
10
|
+
# project .claude/settings.json in the directory a session starts in; gitflash uses the
|
|
11
|
+
# repository's top level, so it applies to sessions started there
|
|
12
|
+
# user ~/.claude/settings.json, for every session
|
|
13
|
+
# Claude Code reloads these files in running sessions, so no restart is needed.
|
|
14
|
+
class Settings
|
|
15
|
+
SCOPES = %w[local project user].freeze
|
|
16
|
+
PATTERN = /\bgitflash hook claude\b/
|
|
17
|
+
|
|
18
|
+
# `repo` is nil outside a git repository; only the user scope applies there
|
|
19
|
+
def initialize(repo)
|
|
20
|
+
@repo = repo
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def path(scope)
|
|
24
|
+
case scope
|
|
25
|
+
when 'user' then File.join(Dir.home, '.claude', 'settings.json')
|
|
26
|
+
when 'project' then File.join(@repo.toplevel, '.claude', 'settings.json')
|
|
27
|
+
else File.join(@repo.main_root, '.claude', 'settings.local.json')
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Which Claude Code sessions read the file of a scope
|
|
32
|
+
def applies_to(scope)
|
|
33
|
+
case scope
|
|
34
|
+
when 'user' then 'every Claude Code session'
|
|
35
|
+
when 'project' then "Claude Code sessions started in #{@repo.toplevel}"
|
|
36
|
+
else "Claude Code sessions in #{@repo.main_root} and its worktrees"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def scopes
|
|
41
|
+
@repo ? SCOPES : %w[user]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def read(path)
|
|
45
|
+
return {} unless File.exist?(path)
|
|
46
|
+
|
|
47
|
+
JSON.parse(File.read(path))
|
|
48
|
+
rescue JSON::ParserError => e
|
|
49
|
+
raise Error.new("#{path} is not valid JSON: #{e.message}", code: 'invalid_settings')
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The gitflash hook entry in parsed settings, or nil
|
|
53
|
+
def entry(settings)
|
|
54
|
+
groups = Array(settings.dig('hooks', 'PreToolUse'))
|
|
55
|
+
groups.flat_map { |group| Array(group['hooks']) }
|
|
56
|
+
.find { |hook| hook['command'].to_s.match?(PATTERN) }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'thor'
|
|
5
|
+
|
|
6
|
+
module Gitflash
|
|
7
|
+
# `gitflash hook ...`: integrations that let agents keep using plain git safely
|
|
8
|
+
class HookCli < Thor
|
|
9
|
+
include CommandRunner
|
|
10
|
+
|
|
11
|
+
namespace 'hook'
|
|
12
|
+
|
|
13
|
+
def self.exit_on_failure?
|
|
14
|
+
true
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
desc 'claude', 'Run as a Claude Code PreToolUse hook (reads the hook input on stdin)'
|
|
18
|
+
long_desc <<~TEXT
|
|
19
|
+
Reads the Claude Code hook input on stdin. When the Bash command can discard work git
|
|
20
|
+
cannot restore (for example reset --hard, clean -f, checkout -- ., branch -D, stash drop),
|
|
21
|
+
it saves a snapshot first and tells Claude how to undo it.
|
|
22
|
+
|
|
23
|
+
--mode snapshot save a snapshot and let the command run (default)
|
|
24
|
+
--mode ask save a snapshot and ask the user to approve the command
|
|
25
|
+
--mode deny block the command and point to the gitflash equivalent
|
|
26
|
+
|
|
27
|
+
Errors never block the command: they are printed to stderr and the hook exits with 0.
|
|
28
|
+
TEXT
|
|
29
|
+
option :mode, type: :string, default: 'snapshot', enum: Hook::Claude::MODES
|
|
30
|
+
def claude
|
|
31
|
+
output = Hook::Claude.new(mode: options[:mode]).call($stdin.read)
|
|
32
|
+
puts JSON.generate(output) if output
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
warn "gitflash hook: #{e.message}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
desc 'install', 'Register the gitflash hook in Claude Code settings'
|
|
38
|
+
long_desc <<~TEXT
|
|
39
|
+
Adds `gitflash hook claude` as a PreToolUse hook for Bash commands. Keeps the rest of the
|
|
40
|
+
settings file unchanged and does nothing when the hook is already installed.
|
|
41
|
+
|
|
42
|
+
--scope local .claude/settings.local.json at the repository root (default, not
|
|
43
|
+
committed); applies to sessions anywhere in the repository and its worktrees
|
|
44
|
+
--scope project .claude/settings.json at the repository top level (shared with the team);
|
|
45
|
+
applies to sessions started there
|
|
46
|
+
--scope user ~/.claude/settings.json; applies to every session
|
|
47
|
+
|
|
48
|
+
Claude Code reloads settings files, so running sessions pick the hook up without a restart.
|
|
49
|
+
Check the result with `gitflash hook status`.
|
|
50
|
+
|
|
51
|
+
Accepts the global --json and --dry-run options after the subcommand: gitflash hook install --json
|
|
52
|
+
TEXT
|
|
53
|
+
option :scope, type: :string, default: 'local', enum: Commands::HookInstall::SCOPES
|
|
54
|
+
option :mode, type: :string, default: 'snapshot', enum: Hook::Claude::MODES
|
|
55
|
+
def install
|
|
56
|
+
run_command('hook install', Commands::HookInstall, repo_required: options[:scope] != 'user')
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
desc 'status', 'Show whether the gitflash hook protects Claude Code sessions here'
|
|
60
|
+
long_desc <<~TEXT
|
|
61
|
+
Lists the Claude Code settings files that apply to the current directory, whether each
|
|
62
|
+
holds the gitflash hook and which sessions it applies to, and whether the gitflash
|
|
63
|
+
executable the hook runs is on PATH. Works outside a git repository (user scope only).
|
|
64
|
+
TEXT
|
|
65
|
+
def status
|
|
66
|
+
run_command('hook status', Commands::HookStatus, repo_required: false)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
data/lib/gitflash/repo.rb
CHANGED
|
@@ -39,6 +39,16 @@ module Gitflash
|
|
|
39
39
|
branches.find(&:current?)&.name
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
def toplevel
|
|
43
|
+
@bash.exec('git', 'rev-parse', '--show-toplevel').strip
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Root of the main checkout; differs from toplevel inside a linked worktree
|
|
47
|
+
def main_root
|
|
48
|
+
common = @bash.exec('git', 'rev-parse', '--path-format=absolute', '--git-common-dir').strip
|
|
49
|
+
File.basename(common) == '.git' ? File.dirname(common) : toplevel
|
|
50
|
+
end
|
|
51
|
+
|
|
42
52
|
# The branch that origin/HEAD points to, else main or master when present locally
|
|
43
53
|
def default_branch
|
|
44
54
|
return @default_branch if defined?(@default_branch)
|