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,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Hook
5
+ # Which git commands create a branch, and the name of it: `checkout -b`, `switch -c`,
6
+ # `branch NAME` and `worktree add -b` (or a path without a commit, which names the branch
7
+ # after the directory). Returns the name, or nil when the command creates no branch.
8
+ module Creations
9
+ NOT_CREATING = %w[
10
+ -d -D --delete -m -M --move -c -C --copy -u --set-upstream-to --unset-upstream
11
+ --edit-description --list -a --all -r --remotes -v -vv --verbose --show-current --contains
12
+ --no-contains --merged --no-merged --points-at
13
+ ].freeze
14
+ WORKTREE_VALUE_OPTIONS = %w[-b -B --reason --orphan].freeze
15
+
16
+ module_function
17
+
18
+ def for(subcommand, args)
19
+ case subcommand
20
+ when 'checkout' then flagged(args, %w[-b -B])
21
+ when 'switch' then flagged(args, %w[-c -C --create --force-create])
22
+ when 'branch' then plain_branch(args)
23
+ when 'worktree' then worktree(args)
24
+ end
25
+ end
26
+
27
+ # The value of the first of `flags`: `-b NAME`, `-bNAME` or `--create=NAME`
28
+ def flagged(args, flags)
29
+ args.each_with_index do |arg, index|
30
+ return args[index + 1] if flags.include?(arg) && args[index + 1]
31
+
32
+ value = attached_value(arg, flags)
33
+ return value if value
34
+ end
35
+ nil
36
+ end
37
+
38
+ def attached_value(arg, flags)
39
+ flags.each do |flag|
40
+ if flag.start_with?('--')
41
+ return arg.delete_prefix("#{flag}=") if arg.start_with?("#{flag}=")
42
+ elsif arg.start_with?(flag) && arg.length > flag.length && !arg.start_with?('--')
43
+ return arg.delete_prefix(flag)
44
+ end
45
+ end
46
+ nil
47
+ end
48
+
49
+ # `git branch NAME [START]` creates NAME; listing, deleting or renaming flags mean it does not
50
+ def plain_branch(args)
51
+ return nil if args.intersect?(NOT_CREATING)
52
+
53
+ args.find { |arg| !arg.start_with?('-') }
54
+ end
55
+
56
+ def worktree(args)
57
+ return nil unless args.first == 'add'
58
+
59
+ rest = args.drop(1)
60
+ named = flagged(rest, %w[-b -B])
61
+ return named if named
62
+ return nil if rest.include?('--detach') || rest.include?('--orphan')
63
+
64
+ path = positional(rest)
65
+ path.size == 1 ? File.basename(path.first) : nil
66
+ end
67
+
68
+ # Arguments that are not options or the values of options that take one
69
+ def positional(args)
70
+ result = []
71
+ skip = false
72
+ args.each do |arg|
73
+ if skip
74
+ skip = false
75
+ elsif WORKTREE_VALUE_OPTIONS.include?(arg)
76
+ skip = true
77
+ elsif !arg.start_with?('-')
78
+ result << arg
79
+ end
80
+ end
81
+ result
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,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,73 @@
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 for an event (PreToolUse by default) in parsed settings, or nil.
53
+ # Only groups whose matcher covers the Bash tool count: a gitflash command under another
54
+ # matcher (Edit, say) never runs for Bash commands.
55
+ def entry(settings, event = 'PreToolUse')
56
+ groups = Array(settings.dig('hooks', event))
57
+ bash = groups.select { |group| bash_matcher?(group['matcher']) }
58
+ bash.flat_map { |group| Array(group['hooks']) }
59
+ .find { |hook| hook['command'].to_s.match?(PATTERN) }
60
+ end
61
+
62
+ # Claude Code matches `matcher` against the tool name: empty or `*` is every tool, anything
63
+ # else is a pattern such as `Bash` or `Bash|Edit`
64
+ def bash_matcher?(matcher)
65
+ return true if matcher.to_s.empty? || matcher == '*'
66
+
67
+ 'Bash'.match?(/\A(?:#{matcher})\z/)
68
+ rescue RegexpError
69
+ false
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,77 @@
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',
18
+ 'Run as a Claude Code hook (PreToolUse and PostToolUse; reads the input on stdin)'
19
+ long_desc <<~TEXT
20
+ Reads the Claude Code hook input on stdin. When the Bash command can discard work git
21
+ cannot restore (for example reset --hard, clean -f, checkout -- ., branch -D, stash drop),
22
+ it saves a snapshot first and tells Claude how to undo it.
23
+
24
+ --mode snapshot save a snapshot and let the command run (default)
25
+ --mode ask save a snapshot and ask the user to approve the command
26
+ --mode deny block the command and point to the gitflash equivalent
27
+
28
+ As a PostToolUse hook it marks the branches the command created (git checkout -b, switch -c,
29
+ branch NAME, worktree add) as agent work, so `gitflash clean --agent` can find them. A branch
30
+ is only marked when it is new (its reflog says it was created a moment ago) and unmarked.
31
+
32
+ Errors never block the command: they are printed to stderr and the hook exits with 0.
33
+ TEXT
34
+ option :mode, type: :string, default: 'snapshot', enum: Hook::Claude::MODES
35
+ def claude
36
+ output = Hook::Claude.new(mode: options[:mode]).call($stdin.read)
37
+ puts JSON.generate(output) if output
38
+ rescue StandardError => e
39
+ warn "gitflash hook: #{e.message}"
40
+ end
41
+
42
+ desc 'install', 'Register the gitflash hook in Claude Code settings'
43
+ long_desc <<~TEXT
44
+ Adds `gitflash hook claude` as a PreToolUse hook (snapshots before destructive commands) and
45
+ as a PostToolUse hook (marks branches an agent creates) for Bash commands. Keeps the rest of
46
+ the settings file unchanged and does nothing when both are already installed. Run it again
47
+ to add the PostToolUse hook to an older install.
48
+
49
+ --scope local .claude/settings.local.json at the repository root (default, not
50
+ committed); applies to sessions anywhere in the repository and its worktrees
51
+ --scope project .claude/settings.json at the repository top level (shared with the team);
52
+ applies to sessions started there
53
+ --scope user ~/.claude/settings.json; applies to every session
54
+
55
+ Claude Code reloads settings files, so running sessions pick the hook up without a restart.
56
+ Check the result with `gitflash hook status`.
57
+
58
+ Accepts the global --json and --dry-run options after the subcommand: gitflash hook install --json
59
+ TEXT
60
+ option :scope, type: :string, default: 'local', enum: Commands::HookInstall::SCOPES
61
+ option :mode, type: :string, default: 'snapshot', enum: Hook::Claude::MODES
62
+ def install
63
+ run_command('hook install', Commands::HookInstall, repo_required: options[:scope] != 'user')
64
+ end
65
+
66
+ desc 'status', 'Show whether the gitflash hook protects Claude Code sessions here'
67
+ long_desc <<~TEXT
68
+ Lists the Claude Code settings files that apply to the current directory, whether each
69
+ holds the gitflash hooks (and whether branch marking is on) and which sessions it applies to,
70
+ and whether the gitflash
71
+ executable the hook runs is on PATH. Works outside a git repository (user scope only).
72
+ TEXT
73
+ def status
74
+ run_command('hook status', Commands::HookStatus, repo_required: false)
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ # Who created a branch: `agent` or `human`. Stored in git config as
5
+ # `branch.<name>.gitflash-owner`, which git removes together with the branch.
6
+ # A branch without a mark counts as human work and is never selected for automatic cleanup.
7
+ class Ownership
8
+ OWNERS = %w[agent human].freeze
9
+ SUFFIX = '.gitflash-owner'
10
+ PREFIX = 'branch.'
11
+
12
+ def initialize(bash: Git::BashCommand)
13
+ @bash = bash
14
+ end
15
+
16
+ # { branch name => owner } for every marked branch
17
+ def all
18
+ stdout, _stderr, success = @bash.capture('git', 'config', '--local', '-z', '--get-regexp',
19
+ '^branch\..+\.gitflash-owner$')
20
+ return {} unless success
21
+
22
+ stdout.split("\0").filter_map { |entry| parse(entry) }.to_h
23
+ end
24
+
25
+ # The branches with their `owner` set from the marks
26
+ def annotate(branches)
27
+ owners = all
28
+ branches.map { |branch| branch.with(owner: owners[branch.name]) }
29
+ end
30
+
31
+ def mark(branch, owner)
32
+ unless OWNERS.include?(owner)
33
+ raise UsageError.new("Unknown owner '#{owner}' (use #{OWNERS.join(' or ')})",
34
+ code: 'invalid_usage')
35
+ end
36
+
37
+ @bash.exec('git', 'config', '--local', key(branch), owner)
38
+ end
39
+
40
+ # Removes the mark; a branch without a mark is already unmarked
41
+ def clear(branch)
42
+ @bash.capture('git', 'config', '--local', '--unset', key(branch))
43
+ end
44
+
45
+ private
46
+
47
+ def key(branch)
48
+ "#{PREFIX}#{branch}#{SUFFIX}"
49
+ end
50
+
51
+ # Entry from `-z`: "branch.<name>.gitflash-owner\nvalue". Branch names may contain dots.
52
+ def parse(entry)
53
+ full_key, value = entry.split("\n", 2)
54
+ return nil unless full_key.start_with?(PREFIX) && full_key.end_with?(SUFFIX)
55
+
56
+ [full_key.delete_prefix(PREFIX).delete_suffix(SUFFIX), value.to_s.strip]
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ # Branches gitflash never deletes: the current and default branch, `main`, `master`, names
5
+ # matching `protected` in the settings, and any branch checked out in a worktree.
6
+ class Protection
7
+ NAMES = %w[main master].freeze
8
+
9
+ def initialize(config:, checked_out: [])
10
+ @config = config
11
+ @checked_out = checked_out
12
+ end
13
+
14
+ def protected?(branch)
15
+ branch.current? || branch.default? || NAMES.include?(branch.name) ||
16
+ @config.protected?(branch.name) || @checked_out.include?(branch.name)
17
+ end
18
+ end
19
+ end
data/lib/gitflash/repo.rb CHANGED
@@ -30,15 +30,24 @@ module Gitflash
30
30
  # merged into the default branch (one extra git call).
31
31
  def branches(merged_status: false)
32
32
  merged_names = merged_status ? merged_branch_names : nil
33
- branch_lines.map do |line|
34
- Branch.parse(line, default_branch: default_branch, merged_names: merged_names)
35
- end
33
+ list = branch_lines.map { |line| Branch.parse(line, default_branch:, merged_names:) }
34
+ Ownership.new(bash: @bash).annotate(list)
36
35
  end
37
36
 
38
37
  def current_branch
39
38
  branches.find(&:current?)&.name
40
39
  end
41
40
 
41
+ def toplevel
42
+ @bash.exec('git', 'rev-parse', '--show-toplevel').strip
43
+ end
44
+
45
+ # Root of the main checkout; differs from toplevel inside a linked worktree
46
+ def main_root
47
+ common = @bash.exec('git', 'rev-parse', '--path-format=absolute', '--git-common-dir').strip
48
+ File.basename(common) == '.git' ? File.dirname(common) : toplevel
49
+ end
50
+
42
51
  # The branch that origin/HEAD points to, else main or master when present locally
43
52
  def default_branch
44
53
  return @default_branch if defined?(@default_branch)
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ # Plans and applies the restore of a snapshot. Only the parts in the snapshot's scope are
5
+ # restored, and only where the repository differs from the snapshot. Branches and files
6
+ # created after the snapshot are left alone.
7
+ class Restore
8
+ def initialize(snapshot, bash: Git::BashCommand)
9
+ @snapshot = snapshot
10
+ @bash = bash
11
+ end
12
+
13
+ # { branches: [{branch, from, to}], head: {from, to} | nil, worktree: bool, stashes: [sha] }
14
+ def plan
15
+ @plan ||= {
16
+ branches: @snapshot.scope?('branches') ? branch_changes : [],
17
+ head: @snapshot.scope?('head') ? head_change : nil,
18
+ worktree: @snapshot.scope?('worktree') && worktree_differs?,
19
+ stashes: @snapshot.scope?('stashes') ? missing_stashes : []
20
+ }
21
+ end
22
+
23
+ # Parts of the state this restore changes; the state before an undo is saved with the same
24
+ def scope = @snapshot.scope
25
+
26
+ def empty?
27
+ plan[:branches].empty? && plan[:head].nil? && !plan[:worktree] && plan[:stashes].empty?
28
+ end
29
+
30
+ # Applies the plan (`only: :branches` just the branch tips); returns nil on success or the
31
+ # failed step's Result
32
+ def apply(only: nil)
33
+ (only == :branches ? branch_steps : steps).lazy.map { |args| run(*args) }
34
+ .find { |result| !result.success? }
35
+ end
36
+
37
+ private
38
+
39
+ def steps
40
+ branch_steps + head_steps + worktree_steps + stash_steps
41
+ end
42
+
43
+ def branch_steps
44
+ plan[:branches].map do |change|
45
+ # An empty old value makes git refuse to overwrite a branch created since the plan
46
+ ['update-ref', "refs/heads/#{change[:branch]}", change[:to], change[:from] || '']
47
+ end
48
+ end
49
+
50
+ # With the worktree in scope HEAD is moved directly and the files are restored next;
51
+ # otherwise a normal checkout keeps local changes.
52
+ def head_steps
53
+ return [] unless plan[:head]
54
+
55
+ branch = @snapshot.head[:branch]
56
+ sha = @snapshot.head[:sha]
57
+ if @snapshot.scope?('worktree')
58
+ detach = ['update-ref', '--no-deref', 'HEAD', sha]
59
+ [branch ? ['symbolic-ref', 'HEAD', "refs/heads/#{branch}"] : detach]
60
+ else
61
+ [branch ? ['switch', '--', branch] : ['checkout', '--detach', sha]]
62
+ end
63
+ end
64
+
65
+ # Working tree first (tracked and snapshot-untracked files), then the staged state
66
+ def worktree_steps
67
+ return [] unless plan[:worktree] && target_worktree
68
+
69
+ steps = [['read-tree', '--reset', '-u', target_worktree]]
70
+ steps << ['read-tree', '--reset', target_index] if target_index != target_worktree
71
+ steps
72
+ end
73
+
74
+ def stash_steps
75
+ plan[:stashes].reverse.map { |sha| ['stash', 'store', '-m', 'gitflash undo', sha] }
76
+ end
77
+
78
+ def target_worktree
79
+ @snapshot.worktree || @snapshot.head[:sha]
80
+ end
81
+
82
+ def target_index
83
+ return @snapshot.index if @snapshot.index
84
+
85
+ @snapshot.worktree ? @snapshot.head[:sha] : target_worktree
86
+ end
87
+
88
+ def branch_changes
89
+ tips = current_tips
90
+ @snapshot.branches.filter_map do |name, sha|
91
+ { branch: name, from: tips[name], to: sha } unless tips[name] == sha
92
+ end
93
+ end
94
+
95
+ def head_change
96
+ current = current_head
97
+ target = @snapshot.head
98
+ same = if target[:branch]
99
+ current[:branch] == target[:branch]
100
+ else
101
+ current[:branch].nil? && current[:sha] == target[:sha]
102
+ end
103
+ same ? nil : { from: current[:branch] || current[:sha], to: target[:branch] || target[:sha] }
104
+ end
105
+
106
+ def current_head
107
+ { branch: capture('symbolic-ref', '--quiet', 'HEAD')&.delete_prefix(Repo::HEADS),
108
+ sha: capture('rev-parse', '--verify', '--quiet', 'HEAD^{commit}') }
109
+ end
110
+
111
+ def worktree_differs?
112
+ return true unless capture('status', '--porcelain', '--untracked-files=all').to_s.empty?
113
+
114
+ target_worktree && tree_of(target_worktree) != tree_of('HEAD')
115
+ end
116
+
117
+ def missing_stashes
118
+ @snapshot.stashes - capture('stash', 'list', '--format=%H').to_s.split
119
+ end
120
+
121
+ def current_tips
122
+ Branch.parse_tips(capture('for-each-ref', "--format=#{Branch::TIPS_FORMAT}", Repo::HEADS))
123
+ end
124
+
125
+ def tree_of(ref)
126
+ capture('rev-parse', '--verify', '--quiet', "#{ref}^{tree}")
127
+ end
128
+
129
+ # Stripped stdout of a git command, or nil when it fails
130
+ def capture(*)
131
+ stdout, _stderr, success = @bash.capture('git', *)
132
+ success ? stdout.strip : nil
133
+ end
134
+
135
+ def run(*)
136
+ stdout, stderr, success = @bash.capture('git', *)
137
+ output = [stdout, stderr].map(&:strip).reject(&:empty?).join("\n")
138
+ Result.new(success: success, output: output)
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ # Saved repository state that `gitflash undo` can restore.
5
+ #
6
+ # scope parts of the state that undo restores: branches, head, worktree, stashes
7
+ # head { branch:, sha: } of HEAD; branch is nil on a detached HEAD, sha nil before
8
+ # the first commit
9
+ # branches { name => sha } of the recorded local branches
10
+ # stashes stash commits, newest first
11
+ # index commit whose tree is the staged state, nil when the tree was clean
12
+ # worktree commit whose tree is the working tree including untracked files (not
13
+ # ignored ones), nil when the tree was clean
14
+ # skipped_files untracked files left out because they were too large
15
+ Snapshot = Data.define(
16
+ :id, :created_at, :reason, :scope, :worktree_path, :head, :branches, :stashes,
17
+ :index, :worktree, :skipped_files
18
+ ) do
19
+ def self.from_h(hash)
20
+ new(
21
+ id: hash['id'], created_at: hash['created_at'], reason: hash['reason'],
22
+ scope: hash['scope'], worktree_path: hash['worktree_path'],
23
+ head: { branch: hash.dig('head', 'branch'), sha: hash.dig('head', 'sha') },
24
+ branches: hash['branches'] || {}, stashes: hash['stashes'] || [],
25
+ index: hash['index'], worktree: hash['worktree'], skipped_files: hash['skipped_files'] || []
26
+ )
27
+ end
28
+
29
+ def scope?(part)
30
+ scope.include?(part)
31
+ end
32
+
33
+ # Commits the snapshot must keep reachable
34
+ def object_ids
35
+ ([head[:sha], index, worktree] + branches.values + stashes).compact.uniq
36
+ end
37
+
38
+ # Everything except id, time and reason, to detect an unchanged repository
39
+ def state
40
+ to_h.except(:id, :created_at, :reason)
41
+ end
42
+
43
+ def to_h
44
+ super.merge(branches: branches.transform_keys(&:to_s))
45
+ end
46
+ end
47
+ end