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.
@@ -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
- 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,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)