gitflash 0.6.0 → 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 +14 -0
- data/README.md +26 -2
- data/command_descriptions.yml +76 -0
- data/lib/gitflash/branch.rb +7 -2
- data/lib/gitflash/cherry_pick.rb +128 -0
- data/lib/gitflash/cleanup.rb +70 -0
- data/lib/gitflash/cli.rb +37 -0
- data/lib/gitflash/commands/base.rb +9 -3
- data/lib/gitflash/commands/branches.rb +1 -0
- data/lib/gitflash/commands/clean.rb +51 -0
- data/lib/gitflash/commands/clean_criteria.rb +29 -0
- data/lib/gitflash/commands/delete.rb +17 -5
- data/lib/gitflash/commands/hook_install.rb +17 -9
- data/lib/gitflash/commands/hook_status.rb +24 -6
- 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/undo.rb +38 -6
- 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/in_directory.rb +29 -0
- data/lib/gitflash/hook/branch_marker.rb +52 -0
- data/lib/gitflash/hook/claude.rb +23 -3
- data/lib/gitflash/hook/command_parser.rb +29 -9
- data/lib/gitflash/hook/creations.rb +85 -0
- data/lib/gitflash/hook/settings.rb +18 -5
- data/lib/gitflash/hook_cli.rb +12 -4
- data/lib/gitflash/ownership.rb +59 -0
- data/lib/gitflash/protection.rb +19 -0
- data/lib/gitflash/repo.rb +2 -3
- data/lib/gitflash/restore.rb +8 -7
- data/lib/gitflash/state_capture.rb +2 -2
- 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 +1050 -2
- metadata +30 -1
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitflash
|
|
4
|
+
module Hook
|
|
5
|
+
# Marks the branches an agent just created as agent work (see Ownership), so
|
|
6
|
+
# `gitflash clean --agent` can find them. Runs in the PostToolUse hook, after the command:
|
|
7
|
+
# the branch exists then, and Claude Code only runs it when the command succeeded.
|
|
8
|
+
#
|
|
9
|
+
# A branch is marked only when it is unmarked and its reflog says it was created a moment
|
|
10
|
+
# ago ("branch: Created from ..."), so a branch that was already there is never claimed.
|
|
11
|
+
class BranchMarker
|
|
12
|
+
CREATED = 'branch: Created from'
|
|
13
|
+
RECENT_SECONDS = 120
|
|
14
|
+
|
|
15
|
+
def initialize(clock: -> { Time.now })
|
|
16
|
+
@clock = clock
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Branch names marked as agent work, in the directories the command ran in
|
|
20
|
+
def call(command, cwd)
|
|
21
|
+
created = CommandParser.new(cwd).creations(command)
|
|
22
|
+
created.select { |creation| mark(creation[:dir], creation[:branch]) }
|
|
23
|
+
.map { |creation| creation[:branch] }.uniq
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def mark(dir, branch)
|
|
29
|
+
return false unless Dir.exist?(dir)
|
|
30
|
+
|
|
31
|
+
Dir.chdir(dir) do
|
|
32
|
+
ownership = Ownership.new
|
|
33
|
+
next false unless created_recently?(branch) && !ownership.all.key?(branch)
|
|
34
|
+
|
|
35
|
+
ownership.mark(branch, 'agent')
|
|
36
|
+
true
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The oldest reflog entry of the branch is its creation, and it is recent
|
|
41
|
+
def created_recently?(branch)
|
|
42
|
+
stdout, _stderr, success = Git::BashCommand.capture(
|
|
43
|
+
'git', 'log', '-g', '--format=%ct%x1f%gs', "#{Repo::HEADS}#{branch}", '--'
|
|
44
|
+
)
|
|
45
|
+
return false unless success
|
|
46
|
+
|
|
47
|
+
created, subject = stdout.lines(chomp: true).last.to_s.split("\x1f", 2)
|
|
48
|
+
subject.to_s.start_with?(CREATED) && @clock.call.to_i - created.to_i <= RECENT_SECONDS
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
data/lib/gitflash/hook/claude.rb
CHANGED
|
@@ -4,7 +4,8 @@ require 'json'
|
|
|
4
4
|
|
|
5
5
|
module Gitflash
|
|
6
6
|
module Hook
|
|
7
|
-
# Claude Code
|
|
7
|
+
# Claude Code hook for the Bash tool. PostToolUse input marks the branches the command
|
|
8
|
+
# created as agent work (BranchMarker); everything below describes PreToolUse.
|
|
8
9
|
#
|
|
9
10
|
# Reads the hook input (JSON on stdin) and, when the command can lose work git cannot
|
|
10
11
|
# restore, acts according to the mode:
|
|
@@ -15,14 +16,20 @@ module Gitflash
|
|
|
15
16
|
class Claude
|
|
16
17
|
MODES = %w[snapshot ask deny].freeze
|
|
17
18
|
EVENT = 'PreToolUse'
|
|
19
|
+
POST_EVENT = 'PostToolUse'
|
|
18
20
|
|
|
19
|
-
def initialize(mode: 'snapshot', snapshots: -> { Gitflash::Snapshots.new }
|
|
21
|
+
def initialize(mode: 'snapshot', snapshots: -> { Gitflash::Snapshots.new },
|
|
22
|
+
marker: BranchMarker.new)
|
|
20
23
|
@mode = mode
|
|
21
24
|
@snapshots = snapshots
|
|
25
|
+
@marker = marker
|
|
22
26
|
end
|
|
23
27
|
|
|
24
28
|
def call(input)
|
|
25
|
-
|
|
29
|
+
data = JSON.parse(input)
|
|
30
|
+
return after_command(data) if data['hook_event_name'] == POST_EVENT
|
|
31
|
+
|
|
32
|
+
targets = targets(data)
|
|
26
33
|
return nil if targets.empty?
|
|
27
34
|
return deny(targets) if @mode == 'deny'
|
|
28
35
|
|
|
@@ -32,6 +39,19 @@ module Gitflash
|
|
|
32
39
|
|
|
33
40
|
private
|
|
34
41
|
|
|
42
|
+
# PostToolUse: mark the branches the command created, and tell Claude
|
|
43
|
+
def after_command(data)
|
|
44
|
+
return nil unless data['tool_name'] == 'Bash'
|
|
45
|
+
|
|
46
|
+
marked = @marker.call(data.dig('tool_input', 'command').to_s, data['cwd'] || Dir.pwd)
|
|
47
|
+
return nil if marked.empty?
|
|
48
|
+
|
|
49
|
+
context = "gitflash marked #{marked.join(', ')} as agent work, so " \
|
|
50
|
+
'`gitflash clean --agent` can find it. Change it with ' \
|
|
51
|
+
'`gitflash mark BRANCH --owner human`.'
|
|
52
|
+
{ hookSpecificOutput: { hookEventName: POST_EVENT, additionalContext: context } }
|
|
53
|
+
end
|
|
54
|
+
|
|
35
55
|
def targets(data)
|
|
36
56
|
return [] unless data['tool_name'] == 'Bash'
|
|
37
57
|
|
|
@@ -23,7 +23,32 @@ module Gitflash
|
|
|
23
23
|
@cwd = cwd
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
# Git commands that can lose work, with what to save before them
|
|
26
27
|
def targets(command_line)
|
|
28
|
+
git_commands(command_line).filter_map do |git|
|
|
29
|
+
rule = Rules.for(git.subcommand, git.args, git.dir)
|
|
30
|
+
next unless rule
|
|
31
|
+
|
|
32
|
+
Target.new(dir: rule.fetch(:dir, git.dir), command: git.command, scope: rule[:scope],
|
|
33
|
+
branches: rule[:branches] || [])
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Branches that git commands in the line create, as { dir:, branch: }
|
|
38
|
+
def creations(command_line)
|
|
39
|
+
git_commands(command_line).filter_map do |git|
|
|
40
|
+
branch = Creations.for(git.subcommand, git.args)
|
|
41
|
+
{ dir: git.dir, branch: branch } if branch
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# A git command found in the line: the directory it runs in (after `cd` and `-C`), the
|
|
48
|
+
# subcommand and its arguments
|
|
49
|
+
GitCommand = Data.define(:dir, :command, :subcommand, :args)
|
|
50
|
+
|
|
51
|
+
def git_commands(command_line)
|
|
27
52
|
dir = @cwd
|
|
28
53
|
segments(command_line).filter_map do |segment|
|
|
29
54
|
words = words(segment)
|
|
@@ -33,12 +58,10 @@ module Gitflash
|
|
|
33
58
|
dir = File.expand_path(words[1] || Dir.home, dir)
|
|
34
59
|
next
|
|
35
60
|
end
|
|
36
|
-
|
|
61
|
+
git_command(words, dir, segment.strip) if File.basename(words.first) == 'git'
|
|
37
62
|
end
|
|
38
63
|
end
|
|
39
64
|
|
|
40
|
-
private
|
|
41
|
-
|
|
42
65
|
# Splits on unquoted ; & | and newlines
|
|
43
66
|
def segments(line)
|
|
44
67
|
parts = [+'']
|
|
@@ -78,13 +101,10 @@ module Gitflash
|
|
|
78
101
|
word.delete_prefix('(').delete_prefix('{').delete_suffix(')').delete_suffix('}')
|
|
79
102
|
end
|
|
80
103
|
|
|
81
|
-
def
|
|
104
|
+
def git_command(words, dir, command)
|
|
82
105
|
index, dir = skip_git_options(words, dir)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
Target.new(dir: rule.fetch(:dir, dir), command: command, scope: rule[:scope],
|
|
87
|
-
branches: rule[:branches] || [])
|
|
106
|
+
GitCommand.new(dir: dir, command: command, subcommand: words[index],
|
|
107
|
+
args: words[(index + 1)..] || [])
|
|
88
108
|
end
|
|
89
109
|
|
|
90
110
|
# Index of the subcommand, and the directory after any -C options
|
|
@@ -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
|
|
@@ -49,11 +49,24 @@ module Gitflash
|
|
|
49
49
|
raise Error.new("#{path} is not valid JSON: #{e.message}", code: 'invalid_settings')
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
-
# The gitflash hook entry in parsed settings, or nil
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
57
70
|
end
|
|
58
71
|
end
|
|
59
72
|
end
|
data/lib/gitflash/hook_cli.rb
CHANGED
|
@@ -14,7 +14,8 @@ module Gitflash
|
|
|
14
14
|
true
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
desc 'claude',
|
|
17
|
+
desc 'claude',
|
|
18
|
+
'Run as a Claude Code hook (PreToolUse and PostToolUse; reads the input on stdin)'
|
|
18
19
|
long_desc <<~TEXT
|
|
19
20
|
Reads the Claude Code hook input on stdin. When the Bash command can discard work git
|
|
20
21
|
cannot restore (for example reset --hard, clean -f, checkout -- ., branch -D, stash drop),
|
|
@@ -24,6 +25,10 @@ module Gitflash
|
|
|
24
25
|
--mode ask save a snapshot and ask the user to approve the command
|
|
25
26
|
--mode deny block the command and point to the gitflash equivalent
|
|
26
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
|
+
|
|
27
32
|
Errors never block the command: they are printed to stderr and the hook exits with 0.
|
|
28
33
|
TEXT
|
|
29
34
|
option :mode, type: :string, default: 'snapshot', enum: Hook::Claude::MODES
|
|
@@ -36,8 +41,10 @@ module Gitflash
|
|
|
36
41
|
|
|
37
42
|
desc 'install', 'Register the gitflash hook in Claude Code settings'
|
|
38
43
|
long_desc <<~TEXT
|
|
39
|
-
Adds `gitflash hook claude` as a PreToolUse hook
|
|
40
|
-
|
|
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.
|
|
41
48
|
|
|
42
49
|
--scope local .claude/settings.local.json at the repository root (default, not
|
|
43
50
|
committed); applies to sessions anywhere in the repository and its worktrees
|
|
@@ -59,7 +66,8 @@ module Gitflash
|
|
|
59
66
|
desc 'status', 'Show whether the gitflash hook protects Claude Code sessions here'
|
|
60
67
|
long_desc <<~TEXT
|
|
61
68
|
Lists the Claude Code settings files that apply to the current directory, whether each
|
|
62
|
-
holds the gitflash
|
|
69
|
+
holds the gitflash hooks (and whether branch marking is on) and which sessions it applies to,
|
|
70
|
+
and whether the gitflash
|
|
63
71
|
executable the hook runs is on PATH. Works outside a git repository (user scope only).
|
|
64
72
|
TEXT
|
|
65
73
|
def status
|
|
@@ -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,9 +30,8 @@ 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
|
data/lib/gitflash/restore.rb
CHANGED
|
@@ -20,17 +20,18 @@ module Gitflash
|
|
|
20
20
|
}
|
|
21
21
|
end
|
|
22
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
|
+
|
|
23
26
|
def empty?
|
|
24
27
|
plan[:branches].empty? && plan[:head].nil? && !plan[:worktree] && plan[:stashes].empty?
|
|
25
28
|
end
|
|
26
29
|
|
|
27
|
-
# Applies the plan; returns nil on success or the
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
end
|
|
33
|
-
nil
|
|
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? }
|
|
34
35
|
end
|
|
35
36
|
|
|
36
37
|
private
|
|
@@ -93,9 +93,9 @@ module Gitflash
|
|
|
93
93
|
# Tree of the working tree, built in a temporary copy of the index so the real one is untouched
|
|
94
94
|
def worktree_tree
|
|
95
95
|
top = git('rev-parse', '--show-toplevel')
|
|
96
|
-
Tempfile.create('gitflash-index',
|
|
96
|
+
Tempfile.create('gitflash-index', git('rev-parse', '--absolute-git-dir')) do |file|
|
|
97
97
|
file.close
|
|
98
|
-
real_index =
|
|
98
|
+
real_index = git('rev-parse', '--path-format=absolute', '--git-path', 'index')
|
|
99
99
|
FileUtils.cp(real_index, file.path) if File.exist?(real_index)
|
|
100
100
|
env = { 'GIT_INDEX_FILE' => file.path }
|
|
101
101
|
|
data/lib/gitflash/version.rb
CHANGED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitflash
|
|
4
|
+
# A checkout of the repository as reported by `git worktree list --porcelain`.
|
|
5
|
+
# `branch` is nil for a detached HEAD. `missing` means the directory is gone (git calls it
|
|
6
|
+
# prunable). `dirty` is nil when it cannot be known (missing directory or bare repository).
|
|
7
|
+
# ahead, behind, merged and owner are those of the branch, nil without a branch.
|
|
8
|
+
Worktree = Data.define(
|
|
9
|
+
:path, :head, :branch, :main, :bare, :locked, :lock_reason, :missing,
|
|
10
|
+
:dirty, :ahead, :behind, :merged, :owner
|
|
11
|
+
) do
|
|
12
|
+
alias_method :main?, :main
|
|
13
|
+
alias_method :locked?, :locked
|
|
14
|
+
alias_method :missing?, :missing
|
|
15
|
+
alias_method :dirty?, :dirty
|
|
16
|
+
|
|
17
|
+
# Why this worktree must not be removed, nil when it can go. `current` is true for the
|
|
18
|
+
# worktree gitflash runs in. The main checkout and the current one are never removable.
|
|
19
|
+
def removal_blocker(force:, current:)
|
|
20
|
+
return "#{path} is the main checkout" if main?
|
|
21
|
+
return "#{path} is the current worktree" if current
|
|
22
|
+
|
|
23
|
+
in_use_blocker unless force || (missing? && !locked?)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def in_use_blocker
|
|
27
|
+
return "#{path} is locked (use --force)" if locked?
|
|
28
|
+
|
|
29
|
+
"#{path} has uncommitted changes (use --force)" if dirty? != false
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'thor'
|
|
4
|
+
|
|
5
|
+
module Gitflash
|
|
6
|
+
# `gitflash worktree ...` (alias `wt`): worktrees for parallel agent work
|
|
7
|
+
class WorktreeCli < Thor
|
|
8
|
+
extend Configuration::Descriptions
|
|
9
|
+
include CommandRunner
|
|
10
|
+
|
|
11
|
+
namespace 'worktree'
|
|
12
|
+
|
|
13
|
+
def self.exit_on_failure?
|
|
14
|
+
true
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
desc 'list', 'List worktrees with branch, dirty and merge state'
|
|
18
|
+
long_desc <<~TEXT
|
|
19
|
+
Lists every worktree of the repository: absolute path, branch, HEAD, whether it has
|
|
20
|
+
uncommitted changes, ahead/behind counts, whether its branch is merged into the default
|
|
21
|
+
branch, lock state and whether its directory is missing.
|
|
22
|
+
|
|
23
|
+
Agents use the absolute paths from `--json`; no directory switching is needed.
|
|
24
|
+
|
|
25
|
+
Accepts the global --json option after the subcommand: gitflash wt list --json
|
|
26
|
+
TEXT
|
|
27
|
+
def list
|
|
28
|
+
run_command('worktree list', Commands::WorktreeList)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
desc 'add BRANCH', 'Add a worktree for a branch (existing, remote or new)'
|
|
32
|
+
long_desc <<~TEXT
|
|
33
|
+
Adds a worktree for BRANCH. An existing local branch is checked out; a branch that exists
|
|
34
|
+
only on origin is created to track it; any other name creates a new branch from HEAD
|
|
35
|
+
(or from --from). New branches are marked as agent work so `gitflash clean` can find them.
|
|
36
|
+
|
|
37
|
+
The directory defaults to ../<repo>.worktrees/<branch> (setting `worktree_dir` in
|
|
38
|
+
.gitflash.yml changes it). The result lists the absolute path: use it, no directory
|
|
39
|
+
switching is needed.
|
|
40
|
+
|
|
41
|
+
Needs no confirmation. Accepts the global --json and --dry-run options after the
|
|
42
|
+
subcommand: gitflash wt add feature --json
|
|
43
|
+
TEXT
|
|
44
|
+
option :path, type: :string, banner: 'DIR', desc: 'Directory for the worktree'
|
|
45
|
+
option :from, type: :string, banner: 'REF', desc: 'Start a new branch from REF (default HEAD)'
|
|
46
|
+
option :owner, type: :string, enum: Ownership::OWNERS,
|
|
47
|
+
desc: 'Mark a new branch as agent (default) or human work'
|
|
48
|
+
def add(branch = nil)
|
|
49
|
+
run_command('worktree add', Commands::WorktreeAdd, *[branch].compact)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
desc 'lock WORKTREE', 'Lock a worktree so prune, remove and clean leave it alone'
|
|
53
|
+
long_desc 'Locks the worktree given as a path or branch. Add --reason to say why.'
|
|
54
|
+
option :reason, type: :string, desc: 'Why the worktree is locked'
|
|
55
|
+
def lock(worktree = nil)
|
|
56
|
+
run_command('worktree lock', Commands::WorktreeLock, *[worktree].compact)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
desc 'unlock WORKTREE', 'Unlock a worktree'
|
|
60
|
+
def unlock(worktree = nil)
|
|
61
|
+
run_command('worktree unlock', Commands::WorktreeUnlock, *[worktree].compact)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
desc 'move WORKTREE PATH', 'Move a worktree to another directory'
|
|
65
|
+
long_desc <<~TEXT
|
|
66
|
+
Moves the worktree (a path or branch) to PATH. The main checkout and the current worktree
|
|
67
|
+
cannot be moved; a locked worktree needs --force.
|
|
68
|
+
TEXT
|
|
69
|
+
option :force, type: :boolean, default: false, desc: 'Also move a locked worktree'
|
|
70
|
+
def move(worktree = nil, destination = nil)
|
|
71
|
+
run_command('worktree move', Commands::WorktreeMove, *[worktree, destination].compact)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
desc 'prune', 'Forget worktrees whose directory is gone'
|
|
75
|
+
long_desc <<~TEXT
|
|
76
|
+
Removes git's record of worktrees whose directory no longer exists. Locked worktrees stay.
|
|
77
|
+
Branches are not touched. Asks for confirmation; pass --yes to skip it, or --dry-run.
|
|
78
|
+
TEXT
|
|
79
|
+
def prune
|
|
80
|
+
run_command('worktree prune', Commands::WorktreePrune)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
desc 'clean', 'Remove worktrees whose branch is merged, gone, stale or agent-created'
|
|
84
|
+
long_desc <<~TEXT
|
|
85
|
+
Removes worktrees whose branch matches the criteria of `gitflash clean`: merged into the
|
|
86
|
+
default branch or upstream gone by default, plus --stale [DAYS] and --agent. The main
|
|
87
|
+
checkout, the current worktree and worktrees on protected branches are never removed;
|
|
88
|
+
locked worktrees and worktrees with uncommitted changes are skipped (and listed with the
|
|
89
|
+
reason) unless --force is given.
|
|
90
|
+
|
|
91
|
+
Each removal saves a snapshot first, as `wt remove` does. Branches are kept: run
|
|
92
|
+
`gitflash clean` afterwards to delete them. Missing directories are handled by `wt prune`.
|
|
93
|
+
|
|
94
|
+
Asks for confirmation; pass --yes to skip it, or --dry-run.
|
|
95
|
+
TEXT
|
|
96
|
+
option :merged, type: :boolean, desc: 'Branches merged into the default branch'
|
|
97
|
+
option :gone, type: :boolean, desc: 'Branches whose upstream branch was deleted'
|
|
98
|
+
option :stale, type: :string, banner: 'DAYS', lazy_default: '',
|
|
99
|
+
desc: 'Branches without commits for DAYS days (default: stale_days setting)'
|
|
100
|
+
option :agent, type: :boolean, desc: 'Branches created by an agent'
|
|
101
|
+
option :force, type: :boolean, default: false,
|
|
102
|
+
desc: 'Also remove locked worktrees and worktrees with uncommitted changes'
|
|
103
|
+
def clean
|
|
104
|
+
run_command('worktree clean', Commands::WorktreeClean)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
desc 'remove [WORKTREE...]', 'Remove worktrees (paths or branch names), saving a snapshot first'
|
|
108
|
+
long_desc descriptions.worktree_remove.long
|
|
109
|
+
option :force, type: :boolean, default: false,
|
|
110
|
+
desc: 'Remove locked worktrees and worktrees with uncommitted changes'
|
|
111
|
+
def remove(*worktrees)
|
|
112
|
+
run_command('worktree remove', Commands::WorktreeRemove, *worktrees)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitflash
|
|
4
|
+
# Brings back a worktree that was removed after a snapshot of it: adds the worktree again on
|
|
5
|
+
# its branch (or detached HEAD) and restores its files. Branch tips are restored before the
|
|
6
|
+
# worktree is added, so a branch deleted or moved since is checked out at its saved tip and its
|
|
7
|
+
# files always match it.
|
|
8
|
+
class WorktreeRevival
|
|
9
|
+
def initialize(snapshot, repo:)
|
|
10
|
+
@snapshot = snapshot
|
|
11
|
+
@repo = repo
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Same shape as an undo plan, plus `recreate_worktree`
|
|
15
|
+
def plan
|
|
16
|
+
head = @snapshot.head
|
|
17
|
+
{ branches: Restore.new(@snapshot).plan[:branches],
|
|
18
|
+
head: { from: nil, to: head[:branch] || head[:sha] },
|
|
19
|
+
worktree: !@snapshot.worktree.nil?, stashes: [], snapshot: @snapshot.id,
|
|
20
|
+
recreate_worktree: @snapshot.worktree_path }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def scope
|
|
24
|
+
%w[branches]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def empty?
|
|
28
|
+
false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Returns nil on success or the failed step's Result
|
|
32
|
+
def apply
|
|
33
|
+
failure = Restore.new(@snapshot).apply(only: :branches)
|
|
34
|
+
return failure if failure
|
|
35
|
+
|
|
36
|
+
result = attach
|
|
37
|
+
return result unless result.success?
|
|
38
|
+
|
|
39
|
+
restore = Restore.new(@snapshot, bash: Git::InDirectory.new(@snapshot.worktree_path))
|
|
40
|
+
restore.apply
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def attach
|
|
46
|
+
worktrees = Worktrees.new(repo: @repo)
|
|
47
|
+
path = @snapshot.worktree_path
|
|
48
|
+
branch = @snapshot.head[:branch]
|
|
49
|
+
sha = @snapshot.head[:sha]
|
|
50
|
+
return worktrees.attach(path, detach_at: sha) unless branch
|
|
51
|
+
|
|
52
|
+
worktrees.attach(path, branch: branch, create_at: sha)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|