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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -0
- data/README.md +69 -2
- data/bin/gitflash +10 -0
- data/command_descriptions.yml +99 -0
- data/lib/gitflash/branch.rb +14 -2
- data/lib/gitflash/cherry_pick.rb +128 -0
- data/lib/gitflash/cleanup.rb +70 -0
- data/lib/gitflash/cli.rb +72 -21
- data/lib/gitflash/command_runner.rb +23 -0
- data/lib/gitflash/commands/base.rb +15 -0
- data/lib/gitflash/commands/branches.rb +1 -0
- data/lib/gitflash/commands/checkout.rb +2 -1
- data/lib/gitflash/commands/clean.rb +51 -0
- data/lib/gitflash/commands/clean_criteria.rb +29 -0
- data/lib/gitflash/commands/delete.rb +23 -8
- data/lib/gitflash/commands/gc.rb +41 -0
- data/lib/gitflash/commands/hook_install.rb +77 -0
- data/lib/gitflash/commands/hook_status.rb +80 -0
- data/lib/gitflash/commands/mark.rb +85 -0
- data/lib/gitflash/commands/pick.rb +131 -0
- data/lib/gitflash/commands/pick_control.rb +67 -0
- data/lib/gitflash/commands/pick_outcome.rb +52 -0
- data/lib/gitflash/commands/removal_report.rb +65 -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 +131 -0
- data/lib/gitflash/commands/worktree_add.rb +98 -0
- data/lib/gitflash/commands/worktree_clean.rb +49 -0
- data/lib/gitflash/commands/worktree_command.rb +28 -0
- data/lib/gitflash/commands/worktree_list.rb +48 -0
- data/lib/gitflash/commands/worktree_lock.rb +54 -0
- data/lib/gitflash/commands/worktree_move.rb +47 -0
- data/lib/gitflash/commands/worktree_prune.rb +41 -0
- data/lib/gitflash/commands/worktree_remove.rb +119 -0
- data/lib/gitflash/commands/worktree_unlock.rb +12 -0
- data/lib/gitflash/config.rb +95 -0
- data/lib/gitflash/config_keys.rb +19 -0
- data/lib/gitflash/git/bash_command.rb +5 -4
- data/lib/gitflash/git/in_directory.rb +29 -0
- data/lib/gitflash/hook/branch_marker.rb +52 -0
- data/lib/gitflash/hook/claude.rb +126 -0
- data/lib/gitflash/hook/command_parser.rb +121 -0
- data/lib/gitflash/hook/creations.rb +85 -0
- data/lib/gitflash/hook/rules.rb +108 -0
- data/lib/gitflash/hook/settings.rb +73 -0
- data/lib/gitflash/hook_cli.rb +77 -0
- data/lib/gitflash/ownership.rb +59 -0
- data/lib/gitflash/protection.rb +19 -0
- data/lib/gitflash/repo.rb +12 -3
- data/lib/gitflash/restore.rb +141 -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/lib/gitflash/worktree.rb +32 -0
- data/lib/gitflash/worktree_cli.rb +115 -0
- data/lib/gitflash/worktree_revival.rb +55 -0
- data/lib/gitflash/worktrees.rb +147 -0
- data/schema/v1.json +1885 -102
- 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
|
|
34
|
-
|
|
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
|