gitflash 0.7.0.beta2 → 0.7.0.beta3
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 +37 -0
- data/README.md +15 -11
- data/command_descriptions.yml +21 -7
- data/lib/gitflash/atomic_file.rb +40 -0
- data/lib/gitflash/cli.rb +27 -4
- data/lib/gitflash/command_runner.rb +12 -1
- data/lib/gitflash/commands/clean.rb +29 -1
- data/lib/gitflash/commands/gc.rb +19 -4
- data/lib/gitflash/commands/hook_install.rb +66 -12
- data/lib/gitflash/commands/mark.rb +9 -3
- data/lib/gitflash/commands/snapshot_create.rb +6 -4
- data/lib/gitflash/commands/undo.rb +18 -8
- data/lib/gitflash/commands/worktree_command.rb +17 -3
- data/lib/gitflash/commands/worktree_remove.rb +14 -5
- data/lib/gitflash/config.rb +63 -13
- data/lib/gitflash/config_keys.rb +52 -4
- data/lib/gitflash/confirmation_required.rb +6 -2
- data/lib/gitflash/hook/claude.rb +17 -12
- data/lib/gitflash/hook/command_parser.rb +91 -68
- data/lib/gitflash/hook/command_words.rb +73 -0
- data/lib/gitflash/hook/failure.rb +29 -0
- data/lib/gitflash/hook/git_invocation.rb +119 -0
- data/lib/gitflash/hook/history_rules.rb +81 -0
- data/lib/gitflash/hook/losses.rb +51 -0
- data/lib/gitflash/hook/messages.rb +28 -2
- data/lib/gitflash/hook/paths.rb +47 -0
- data/lib/gitflash/hook/rules.rb +31 -9
- data/lib/gitflash/hook/settings.rb +19 -2
- data/lib/gitflash/hook/shell_quotes.rb +64 -0
- data/lib/gitflash/hook/shell_text.rb +101 -0
- data/lib/gitflash/hook/wrappers.rb +67 -0
- data/lib/gitflash/hook_cli.rb +27 -4
- data/lib/gitflash/protection.rb +12 -4
- data/lib/gitflash/snapshot.rb +9 -2
- data/lib/gitflash/snapshot_check.rb +60 -0
- data/lib/gitflash/snapshots.rb +26 -3
- data/lib/gitflash/state_capture.rb +18 -21
- data/lib/gitflash/ui.rb +31 -3
- data/lib/gitflash/untracked_files.rb +19 -0
- data/lib/gitflash/usage_report.rb +66 -0
- data/lib/gitflash/version.rb +1 -1
- data/schema/v1.json +39 -2
- metadata +14 -1
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
module Gitflash
|
|
4
4
|
module Commands
|
|
5
|
-
# Restores a snapshot (the latest one
|
|
6
|
-
# so an undo can itself be undone.
|
|
5
|
+
# Restores a snapshot (by default the latest one taken in this worktree). The current state
|
|
6
|
+
# is saved first, so an undo can itself be undone.
|
|
7
7
|
class Undo < Base
|
|
8
8
|
def call(id = nil)
|
|
9
9
|
snapshot = find(id)
|
|
10
10
|
return no_snapshots unless snapshot
|
|
11
11
|
|
|
12
12
|
restore = build_restore(snapshot)
|
|
13
|
-
plan = restore.plan.merge(snapshot: snapshot.id)
|
|
13
|
+
plan = restore.plan.merge(snapshot: snapshot.id, reason: snapshot.reason)
|
|
14
14
|
return nothing_to_undo(plan) if restore.empty?
|
|
15
15
|
|
|
16
16
|
restore!(snapshot, restore, plan)
|
|
@@ -18,12 +18,19 @@ module Gitflash
|
|
|
18
18
|
|
|
19
19
|
private
|
|
20
20
|
|
|
21
|
+
# Snapshots are shared by all worktrees of the repository, and another agent may have
|
|
22
|
+
# taken the newest one elsewhere; without an ID only this worktree's count
|
|
21
23
|
def find(id)
|
|
22
|
-
return snapshots.list.
|
|
24
|
+
return snapshots.list.find { |snapshot| own?(snapshot) } if id.nil?
|
|
23
25
|
|
|
24
26
|
snapshots.find(id) || usage_error!('unknown_snapshot', "Unknown snapshot '#{id}'")
|
|
25
27
|
end
|
|
26
28
|
|
|
29
|
+
# Taken in this worktree; snapshots from before worktree_path was recorded count too
|
|
30
|
+
def own?(snapshot)
|
|
31
|
+
snapshot.worktree_path.nil? || snapshot.worktree_path == repo.toplevel
|
|
32
|
+
end
|
|
33
|
+
|
|
27
34
|
# A snapshot of a worktree that is gone is restored by adding the worktree again
|
|
28
35
|
def build_restore(snapshot)
|
|
29
36
|
return WorktreeRevival.new(snapshot, repo: repo) if removed_worktree?(snapshot)
|
|
@@ -34,14 +41,14 @@ module Gitflash
|
|
|
34
41
|
|
|
35
42
|
# The snapshot was taken in a worktree whose directory no longer exists
|
|
36
43
|
def removed_worktree?(snapshot)
|
|
37
|
-
(snapshot.scope?('head') || snapshot.scope?('worktree')) &&
|
|
38
|
-
|
|
44
|
+
!own?(snapshot) && (snapshot.scope?('head') || snapshot.scope?('worktree')) &&
|
|
45
|
+
!File.exist?(snapshot.worktree_path)
|
|
39
46
|
end
|
|
40
47
|
|
|
41
48
|
# Branches are shared between worktrees; HEAD and files belong to one worktree
|
|
42
49
|
def check_worktree!(snapshot)
|
|
43
50
|
return unless snapshot.scope?('head') || snapshot.scope?('worktree')
|
|
44
|
-
return if snapshot
|
|
51
|
+
return if own?(snapshot)
|
|
45
52
|
|
|
46
53
|
usage_error!('wrong_worktree', "Snapshot #{snapshot.id} was taken in " \
|
|
47
54
|
"#{snapshot.worktree_path}; run gitflash undo there")
|
|
@@ -86,7 +93,10 @@ module Gitflash
|
|
|
86
93
|
end
|
|
87
94
|
|
|
88
95
|
def no_snapshots
|
|
89
|
-
ui.report(status: 'noop', result: { snapshot: nil },
|
|
96
|
+
ui.report(status: 'noop', result: { snapshot: nil },
|
|
97
|
+
text: 'No snapshots of this worktree to undo. `gitflash snapshots` lists all: ' \
|
|
98
|
+
'run `gitflash undo ID` in the worktree a snapshot was taken in (an ' \
|
|
99
|
+
'ID from elsewhere works here only for a branches-only snapshot)')
|
|
90
100
|
end
|
|
91
101
|
|
|
92
102
|
def nothing_to_undo(plan)
|
|
@@ -11,10 +11,24 @@ module Gitflash
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
# The worktree named by a path or a branch; raises `unknown_worktree` when none matches
|
|
14
|
+
# A target that names one worktree by path and another by branch is refused, so a command
|
|
15
|
+
# never acts on the worktree the caller did not mean
|
|
14
16
|
def find!(target, list = worktrees.list)
|
|
15
|
-
path =
|
|
16
|
-
list.
|
|
17
|
-
|
|
17
|
+
path = expand(target)
|
|
18
|
+
found = list.select { |worktree| worktree.path == path || worktree.branch == target }
|
|
19
|
+
usage_error!('unknown_worktree', "Unknown worktree: #{target}") if found.empty?
|
|
20
|
+
return found.first if found.one?
|
|
21
|
+
|
|
22
|
+
paths = found.map { |worktree| TerminalText.line(worktree.path) }.join(', ')
|
|
23
|
+
usage_error!('invalid_usage', "#{target} names more than one worktree (#{paths}); " \
|
|
24
|
+
'pass the full path')
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# File.expand_path, but `~name` for an unknown user is a usage error, not a crash
|
|
28
|
+
def expand(path)
|
|
29
|
+
File.expand_path(path)
|
|
30
|
+
rescue ArgumentError => e
|
|
31
|
+
usage_error!('invalid_options', "Cannot read the path #{path}: #{e.message}")
|
|
18
32
|
end
|
|
19
33
|
|
|
20
34
|
def refuse_main!(worktree, action)
|
|
@@ -6,7 +6,8 @@ module Gitflash
|
|
|
6
6
|
# The main checkout, the worktree gitflash runs in, locked and dirty worktrees are refused
|
|
7
7
|
# unless --force is given (the main checkout and the current one never can be removed).
|
|
8
8
|
# Each removal saves a snapshot of the worktree first; `gitflash undo ID` brings the
|
|
9
|
-
# directory, its branch and its uncommitted files back.
|
|
9
|
+
# directory, its branch and its uncommitted files back. Ignored files cannot be saved, so a
|
|
10
|
+
# removal that deletes some needs --confirm TOKEN instead of --yes.
|
|
10
11
|
class WorktreeRemove < WorktreeCommand
|
|
11
12
|
include RemovalReport
|
|
12
13
|
|
|
@@ -56,7 +57,11 @@ module Gitflash
|
|
|
56
57
|
def remove(chosen)
|
|
57
58
|
plan = build_plan(chosen)
|
|
58
59
|
return planned(plan, "Would remove:\n#{preview(chosen, plan)}") if dry_run?
|
|
59
|
-
|
|
60
|
+
|
|
61
|
+
# Ignored files are never saved, so losing them needs --confirm TOKEN, not --yes
|
|
62
|
+
confirmed = ui.confirm?(summary(chosen, plan), plan: plan,
|
|
63
|
+
irreversible: loses_ignored?(plan))
|
|
64
|
+
return cancelled(plan) unless confirmed
|
|
60
65
|
|
|
61
66
|
outcomes = chosen.map { |worktree| remove_one(worktree) }
|
|
62
67
|
report_results(plan, outcomes)
|
|
@@ -64,6 +69,10 @@ module Gitflash
|
|
|
64
69
|
|
|
65
70
|
def dry_run? = ui.dry_run?
|
|
66
71
|
|
|
72
|
+
def loses_ignored?(plan)
|
|
73
|
+
plan[:worktrees].any? { |row| row[:ignored_count].positive? }
|
|
74
|
+
end
|
|
75
|
+
|
|
67
76
|
def preview(chosen, plan) = "#{bullets(chosen)}#{ignored_warning(plan)}"
|
|
68
77
|
|
|
69
78
|
# The plan reported and returned in JSON; `wt clean` adds why each worktree was chosen
|
|
@@ -79,7 +88,7 @@ module Gitflash
|
|
|
79
88
|
|
|
80
89
|
def remove_one(worktree)
|
|
81
90
|
snapshot = snapshot_for(worktree)
|
|
82
|
-
unsaved = snapshot.
|
|
91
|
+
unsaved = snapshot.unsaved_files
|
|
83
92
|
return refuse_unsaved(worktree, unsaved) if unsaved.any?
|
|
84
93
|
|
|
85
94
|
result = Worktrees.new(repo: repo).remove(worktree.path, force: force?)
|
|
@@ -90,8 +99,8 @@ module Gitflash
|
|
|
90
99
|
def refuse_unsaved(worktree, files)
|
|
91
100
|
shown = files.first(3).join(', ')
|
|
92
101
|
shown += ", and #{files.size - 3} more" if files.size > 3
|
|
93
|
-
message = "#{files.size} untracked file(s)
|
|
94
|
-
"
|
|
102
|
+
message = "#{files.size} untracked file(s) cannot be saved by a snapshot (over 50 MB or " \
|
|
103
|
+
"matched by snapshot_exclude: #{shown}); move or delete them first"
|
|
95
104
|
Removal.new(worktree: worktree, result: Result.new(success: false, output: message),
|
|
96
105
|
snapshot: nil)
|
|
97
106
|
end
|
data/lib/gitflash/config.rb
CHANGED
|
@@ -4,31 +4,52 @@ require 'yaml'
|
|
|
4
4
|
|
|
5
5
|
module Gitflash
|
|
6
6
|
# Settings from `.gitflash.yml` in the main checkout and `~/.config/gitflash.yml`.
|
|
7
|
-
# Precedence: repository file, then user file, then built-in defaults
|
|
7
|
+
# Precedence: repository file, then user file, then built-in defaults; for `worktree_dir` the
|
|
8
|
+
# user file wins, since it decides where on the user's disk checkouts are written.
|
|
8
9
|
# Command-line flags are applied by the commands themselves and win over all of these.
|
|
9
10
|
# An unknown key or a value of the wrong type raises a UsageError, so a typo never goes unnoticed.
|
|
10
|
-
Config = Data.define(:protected_patterns, :stale_days, :worktree_dir
|
|
11
|
+
Config = Data.define(:protected_patterns, :stale_days, :worktree_dir, :worktree_dir_from_repo,
|
|
12
|
+
:snapshot_exclude) do
|
|
11
13
|
# `root` is the main checkout; `home` is where the user file lives. The repository file wins
|
|
12
14
|
# key by key, except `protected`: both files add patterns, so a repository can protect more
|
|
13
|
-
# branches but never remove what the user protects
|
|
15
|
+
# branches but never remove what the user protects; and `worktree_dir`, where the user file
|
|
16
|
+
# wins. A cloned repository's file may only place worktrees next to the main checkout.
|
|
14
17
|
def self.load(root:, home: Dir.home)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
values =
|
|
18
|
-
new(protected_patterns:
|
|
19
|
-
|
|
18
|
+
user = read(File.join(home, ConfigKeys::USER_FILE))
|
|
19
|
+
repo = read(File.join(root, ConfigKeys::REPO_FILE), repository: true)
|
|
20
|
+
values = ConfigKeys::DEFAULTS.merge(user).merge(repo)
|
|
21
|
+
new(protected_patterns: both([user, repo], 'protected'), stale_days: values['stale_days'],
|
|
22
|
+
snapshot_exclude: both([user, repo], 'snapshot_exclude'), **worktree_dir_of(user, repo))
|
|
20
23
|
end
|
|
21
24
|
|
|
25
|
+
# Lists that both files add to
|
|
26
|
+
def self.both(layers, key)
|
|
27
|
+
layers.flat_map { |layer| layer.fetch(key, []) }.uniq
|
|
28
|
+
end
|
|
29
|
+
private_class_method :both
|
|
30
|
+
|
|
31
|
+
# The user file wins for worktree_dir; a value from the repository is checked again where
|
|
32
|
+
# it leads (see ConfigKeys.check_contained!)
|
|
33
|
+
def self.worktree_dir_of(user, repo)
|
|
34
|
+
from_repo = !user.key?('worktree_dir') && repo.key?('worktree_dir')
|
|
35
|
+
dir = from_repo ? repo['worktree_dir'] : user.fetch('worktree_dir', ConfigKeys::DEFAULT_WORKTREE_DIR)
|
|
36
|
+
{ worktree_dir: dir,
|
|
37
|
+
worktree_dir_from_repo: from_repo }
|
|
38
|
+
end
|
|
39
|
+
private_class_method :worktree_dir_of
|
|
40
|
+
|
|
22
41
|
def self.defaults
|
|
23
|
-
new(protected_patterns: [], stale_days: 30, worktree_dir: ConfigKeys::DEFAULT_WORKTREE_DIR
|
|
42
|
+
new(protected_patterns: [], stale_days: 30, worktree_dir: ConfigKeys::DEFAULT_WORKTREE_DIR,
|
|
43
|
+
worktree_dir_from_repo: false, snapshot_exclude: [])
|
|
24
44
|
end
|
|
25
45
|
|
|
26
|
-
def self.read(path)
|
|
46
|
+
def self.read(path, repository: false)
|
|
27
47
|
return {} unless File.file?(path)
|
|
28
48
|
|
|
29
49
|
reject_duplicate_keys!(path)
|
|
30
50
|
data = YAML.safe_load_file(path) || {}
|
|
31
51
|
validate!(data, path)
|
|
52
|
+
check_repository_dir!(data['worktree_dir'], path) if repository
|
|
32
53
|
data
|
|
33
54
|
rescue Psych::Exception, SystemCallError, IOError => e
|
|
34
55
|
raise invalid(path, "cannot be read (#{e.message.lines.first.strip})")
|
|
@@ -77,14 +98,41 @@ module Gitflash
|
|
|
77
98
|
end
|
|
78
99
|
private_class_method :check_types!
|
|
79
100
|
|
|
101
|
+
# A repository's file comes with the clone, so its worktree_dir must stay next to the main
|
|
102
|
+
# checkout: relative, no `~`, and not climbing above the main checkout's parent
|
|
103
|
+
def self.check_repository_dir!(template, path)
|
|
104
|
+
return if template.nil? || ConfigKeys.contained?(template)
|
|
105
|
+
|
|
106
|
+
raise invalid(path, "'worktree_dir' must be a relative path that stays next to the " \
|
|
107
|
+
'repository (set other places in ~/.config/gitflash.yml)')
|
|
108
|
+
end
|
|
109
|
+
private_class_method :check_repository_dir!
|
|
110
|
+
|
|
80
111
|
def self.invalid(path, problem)
|
|
81
112
|
UsageError.new("#{path} #{problem}", code: 'invalid_config')
|
|
82
113
|
end
|
|
83
114
|
private_class_method :invalid
|
|
84
115
|
|
|
85
|
-
# True when the branch name matches a configured pattern (shell-style
|
|
116
|
+
# True when the branch name matches a configured pattern (shell-style `*`, `?`, `[abc]` and
|
|
117
|
+
# `{a,b}`)
|
|
86
118
|
def protected?(name)
|
|
87
|
-
protected_patterns.any? { |pattern| File.fnmatch?(pattern, name) }
|
|
119
|
+
protected_patterns.any? { |pattern| File.fnmatch?(pattern, name, File::FNM_EXTGLOB) }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# True when an untracked file must stay out of snapshots: the pattern matches its path or
|
|
123
|
+
# its name (`*.pem`, `.env*`, `secrets/*`)
|
|
124
|
+
def snapshot_excluded?(path)
|
|
125
|
+
flags = File::FNM_PATHNAME | File::FNM_DOTMATCH
|
|
126
|
+
names = excluded_candidates(path)
|
|
127
|
+
snapshot_exclude.any? { |pattern| names.any? { |name| File.fnmatch?(pattern, name, flags) } }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The path, its name, and every directory above it: a pattern that matches a directory
|
|
131
|
+
# (`secrets`, `secrets/*`) covers everything below it, as in .gitignore
|
|
132
|
+
def excluded_candidates(path)
|
|
133
|
+
parts = path.split('/')
|
|
134
|
+
dirs = (1...parts.size).map { |count| parts.first(count).join('/') }
|
|
135
|
+
[path, File.basename(path), *dirs]
|
|
88
136
|
end
|
|
89
137
|
|
|
90
138
|
# Directory for a new worktree. `%<repo>s` and `%<branch>s` are replaced as plain text; a
|
|
@@ -92,7 +140,9 @@ module Gitflash
|
|
|
92
140
|
# folders.
|
|
93
141
|
def worktree_path(root:, branch:)
|
|
94
142
|
path = worktree_dir.gsub('%<repo>s') { File.basename(root) }.gsub('%<branch>s') { branch }
|
|
95
|
-
File.expand_path(path, root)
|
|
143
|
+
path = File.expand_path(path, root)
|
|
144
|
+
ConfigKeys.check_contained!(path, root) if worktree_dir_from_repo
|
|
145
|
+
path
|
|
96
146
|
end
|
|
97
147
|
end
|
|
98
148
|
end
|
data/lib/gitflash/config_keys.rb
CHANGED
|
@@ -7,18 +7,51 @@ module Gitflash
|
|
|
7
7
|
USER_FILE = File.join('.config', 'gitflash.yml')
|
|
8
8
|
DEFAULT_WORKTREE_DIR = '../%<repo>s.worktrees/%<branch>s'
|
|
9
9
|
DEFAULTS = {
|
|
10
|
-
'protected' => [], 'stale_days' => 30, 'worktree_dir' => DEFAULT_WORKTREE_DIR
|
|
10
|
+
'protected' => [], 'stale_days' => 30, 'worktree_dir' => DEFAULT_WORKTREE_DIR,
|
|
11
|
+
'snapshot_exclude' => []
|
|
11
12
|
}.freeze
|
|
12
13
|
PLACEHOLDERS = ['%<repo>s', '%<branch>s'].freeze
|
|
13
14
|
MAX_TEMPLATE_LENGTH = 512
|
|
14
15
|
CHECKS = {
|
|
15
|
-
'protected' => [->(v) { v.is_a?(Array) && v.all?(
|
|
16
|
-
'a list of branch name patterns'],
|
|
16
|
+
'protected' => [->(v) { v.is_a?(Array) && v.all? { |pattern| branch_pattern?(pattern) } },
|
|
17
|
+
'a list of branch name patterns (no spaces, control characters, ~, ^ or :)'],
|
|
17
18
|
'stale_days' => [->(v) { v.is_a?(Integer) && v.positive? }, 'a positive whole number'],
|
|
18
19
|
'worktree_dir' => [->(v) { template?(v) },
|
|
19
|
-
'a path of up to 512 characters that uses only %<repo>s and %<branch>s']
|
|
20
|
+
'a path of up to 512 characters that uses only %<repo>s and %<branch>s'],
|
|
21
|
+
'snapshot_exclude' => [->(v) { v.is_a?(Array) && v.all?(String) },
|
|
22
|
+
'a list of file name patterns']
|
|
20
23
|
}.freeze
|
|
21
24
|
|
|
25
|
+
# Whether a template resolves inside the parent of the main checkout, for any repository and
|
|
26
|
+
# branch name (branch names cannot hold `..`)
|
|
27
|
+
def self.contained?(template)
|
|
28
|
+
return false if template.start_with?('/', '~')
|
|
29
|
+
|
|
30
|
+
root = '/parent/repo'
|
|
31
|
+
path = template.gsub('%<repo>s', 'repo').gsub('%<branch>s', 'branch')
|
|
32
|
+
File.expand_path(path, root).start_with?("#{File.dirname(root)}/")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# A pattern that can match a branch name: characters git refuses in branch names, such as a
|
|
36
|
+
# space at either end, would make it match nothing without any warning
|
|
37
|
+
def self.branch_pattern?(value)
|
|
38
|
+
return false unless value.is_a?(String) && !value.empty?
|
|
39
|
+
return false if value.match?(/[\x00-\x20\x7F~^:\\]/)
|
|
40
|
+
|
|
41
|
+
# `..` is never in a branch name; inside `{..,a}` it is only one of the alternatives
|
|
42
|
+
alternatives(value).any? { |pattern| !pattern.include?('..') }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The patterns `{a,b}` stands for, innermost braces first, as File::FNM_EXTGLOB reads them
|
|
46
|
+
def self.alternatives(pattern)
|
|
47
|
+
match = pattern.match(/\{([^{}]*)\}/)
|
|
48
|
+
return [pattern] unless match
|
|
49
|
+
|
|
50
|
+
match[1].split(',', -1).flat_map do |choice|
|
|
51
|
+
alternatives(match.pre_match + choice + match.post_match)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
22
55
|
# A path template: the two placeholders are the only `%` directives, so a width such as
|
|
23
56
|
# `%<repo>999999999s` can never make Ruby build a huge string
|
|
24
57
|
def self.template?(value)
|
|
@@ -27,5 +60,20 @@ module Gitflash
|
|
|
27
60
|
|
|
28
61
|
!PLACEHOLDERS.reduce(value) { |rest, placeholder| rest.gsub(placeholder, '') }.include?('%')
|
|
29
62
|
end
|
|
63
|
+
|
|
64
|
+
# A repository's worktree_dir passed a check on the text alone; a symbolic link in the
|
|
65
|
+
# repository (`wt-link -> /elsewhere`) could still lead out, so the real location of the
|
|
66
|
+
# deepest existing directory on the way must stay next to the main checkout too
|
|
67
|
+
def self.check_contained!(path, root)
|
|
68
|
+
parent = File.realpath(File.dirname(root))
|
|
69
|
+
existing = path
|
|
70
|
+
existing = File.dirname(existing) until File.exist?(existing)
|
|
71
|
+
real = File.join(File.realpath(existing), path.delete_prefix(existing))
|
|
72
|
+
return if real.start_with?("#{parent}/")
|
|
73
|
+
|
|
74
|
+
raise UsageError.new("worktree_dir in #{REPO_FILE} leads to #{real}, outside " \
|
|
75
|
+
"#{parent}; set worktree_dir in ~/.config/gitflash.yml to use it",
|
|
76
|
+
code: 'invalid_config')
|
|
77
|
+
end
|
|
30
78
|
end
|
|
31
79
|
end
|
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
module Gitflash
|
|
4
4
|
# Raised when a destructive command runs without a terminal and without --yes.
|
|
5
|
-
# Carries the plan so the caller can review it and re-run with --yes
|
|
5
|
+
# Carries the plan so the caller can review it and re-run with --yes, or, for a change
|
|
6
|
+
# gitflash undo cannot revert, the token to pass to --confirm.
|
|
6
7
|
class ConfirmationRequired < Error
|
|
7
|
-
|
|
8
|
+
attr_reader :confirm_token
|
|
9
|
+
|
|
10
|
+
def initialize(message = nil, plan: nil, confirm_token: nil)
|
|
8
11
|
super(message, code: 'confirmation_required', plan: plan)
|
|
12
|
+
@confirm_token = confirm_token
|
|
9
13
|
end
|
|
10
14
|
|
|
11
15
|
def exit_code
|
data/lib/gitflash/hook/claude.rb
CHANGED
|
@@ -41,9 +41,11 @@ module Gitflash
|
|
|
41
41
|
return nil if targets.empty?
|
|
42
42
|
return deny(targets) if @mode == 'deny'
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
merged = merge_by_dir(targets)
|
|
45
|
+
saved = merged.filter_map { |target| save(target) }
|
|
46
|
+
# Asking is the user's protection: it must not depend on a snapshot having been saved.
|
|
47
|
+
# A command that runs where nothing could be saved is always reported to the agent.
|
|
48
|
+
output(saved, merged - saved.map(&:first))
|
|
47
49
|
end
|
|
48
50
|
|
|
49
51
|
# Recording is a side feature: when it fails, snapshots and the ask or deny decision still run
|
|
@@ -84,15 +86,11 @@ module Gitflash
|
|
|
84
86
|
|
|
85
87
|
# One snapshot per directory, covering every command run there
|
|
86
88
|
def merge_by_dir(targets)
|
|
87
|
-
targets.group_by(&:dir).map
|
|
88
|
-
CommandParser::Target.new(
|
|
89
|
-
dir: dir, command: group.map(&:command).join(' && '),
|
|
90
|
-
scope: group.flat_map(&:scope).uniq, branches: group.flat_map(&:branches).uniq
|
|
91
|
-
)
|
|
92
|
-
end
|
|
89
|
+
targets.group_by(&:dir).map { |dir, group| CommandParser::Target.merge(dir, group) }
|
|
93
90
|
end
|
|
94
91
|
|
|
95
|
-
# [target, snapshot], or nil when the directory is not a git work tree
|
|
92
|
+
# [target, snapshot], or nil when the directory is not a git work tree or saving failed.
|
|
93
|
+
# A failure must not stop the ask decision, so it is warned about here and not raised.
|
|
96
94
|
def save(target)
|
|
97
95
|
return nil unless Dir.exist?(target.dir)
|
|
98
96
|
|
|
@@ -103,6 +101,9 @@ module Gitflash
|
|
|
103
101
|
scope: target.scope, branches: branches(target))
|
|
104
102
|
[target, snapshot]
|
|
105
103
|
end
|
|
104
|
+
rescue StandardError => e
|
|
105
|
+
warn "gitflash hook: could not save a snapshot in #{target.dir} (#{e.message})"
|
|
106
|
+
nil
|
|
106
107
|
end
|
|
107
108
|
|
|
108
109
|
# Full ref shortened by removing refs/heads/ only; `--short` changes when a tag has the name
|
|
@@ -110,14 +111,18 @@ module Gitflash
|
|
|
110
111
|
git('symbolic-ref', '--quiet', 'HEAD')&.delete_prefix(Repo::HEADS)
|
|
111
112
|
end
|
|
112
113
|
|
|
114
|
+
# The branches to save; nil (all of them) when the command does not name its branches, as
|
|
115
|
+
# in `xargs git branch -D < list`
|
|
113
116
|
def branches(target)
|
|
117
|
+
return nil if target.branches.empty?
|
|
118
|
+
|
|
114
119
|
target.branches.filter_map do |name|
|
|
115
120
|
name == :current ? current_branch : name
|
|
116
121
|
end
|
|
117
122
|
end
|
|
118
123
|
|
|
119
|
-
def output(saved,
|
|
120
|
-
context =
|
|
124
|
+
def output(saved, unsaved)
|
|
125
|
+
context = Messages.context(saved, unsaved)
|
|
121
126
|
specific = { hookEventName: EVENT, additionalContext: context }
|
|
122
127
|
if @mode == 'ask'
|
|
123
128
|
specific[:permissionDecision] = 'ask'
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'shellwords'
|
|
4
|
-
|
|
5
3
|
module Gitflash
|
|
6
4
|
module Hook
|
|
7
5
|
# Finds git commands in a shell command line that can lose work git itself cannot restore:
|
|
@@ -13,16 +11,42 @@ module Gitflash
|
|
|
13
11
|
# `cd DIR` and `git -C DIR`, and may report a command more often than needed. An extra
|
|
14
12
|
# snapshot is harmless; a missed one is not.
|
|
15
13
|
class CommandParser
|
|
16
|
-
# dir: where to take the snapshot; branches: names, or :current for the checked-out one
|
|
17
|
-
|
|
14
|
+
# dir: where to take the snapshot; branches: names, or :current for the checked-out one;
|
|
15
|
+
# guessed: the shell decides the directory (`cd "$REPO"`), so dir is only a fallback;
|
|
16
|
+
# ignored: whether the command also deletes ignored files, which no snapshot saves
|
|
17
|
+
Target = Data.define(:dir, :command, :scope, :branches, :guessed, :ignored) do
|
|
18
|
+
# One target for all the commands of a group that run in dir
|
|
19
|
+
def self.merge(dir, group)
|
|
20
|
+
new(dir: dir, command: group.map(&:command).join(' && '),
|
|
21
|
+
scope: group.flat_map(&:scope).uniq, branches: merged_branches(group),
|
|
22
|
+
guessed: group.any?(&:guessed), ignored: group.any?(&:ignored))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# A target that saves branches without naming them saves every branch, so the merge
|
|
26
|
+
# does too: naming the others' branches would drop the ones it could not name
|
|
27
|
+
def self.merged_branches(group)
|
|
28
|
+
all = group.any? { |target| target.scope.include?('branches') && target.branches.empty? }
|
|
29
|
+
all ? [] : group.flat_map(&:branches).uniq
|
|
30
|
+
end
|
|
31
|
+
end
|
|
18
32
|
|
|
19
|
-
PREFIXES = %w[sudo command exec time nohup env].freeze
|
|
20
33
|
SHELLS = %w[bash sh zsh dash ksh].freeze
|
|
21
34
|
MAX_NESTING = 3
|
|
22
|
-
|
|
35
|
+
# Subcommands git never lets an alias replace and that have no rule, so their aliases need
|
|
36
|
+
# not be read
|
|
37
|
+
BUILTINS = (Rules::RULES.keys + %w[status log diff show add commit push pull fetch rev-parse
|
|
38
|
+
config ls-files remote describe blame grep tag init
|
|
39
|
+
clone mv]).freeze
|
|
40
|
+
|
|
41
|
+
DIR_COMMANDS = %w[cd pushd popd].freeze
|
|
23
42
|
|
|
24
|
-
|
|
43
|
+
# `aliases` returns the git aliases configured for a directory, as { name => value }
|
|
44
|
+
def initialize(cwd, aliases: GitInvocation.method(:configured_aliases))
|
|
25
45
|
@cwd = cwd
|
|
46
|
+
@aliases = aliases
|
|
47
|
+
@alias_cache = {}
|
|
48
|
+
@invocation = GitInvocation.new(dir_after: method(:change_dir),
|
|
49
|
+
aliases: method(:aliases_in))
|
|
26
50
|
end
|
|
27
51
|
|
|
28
52
|
# Git commands that can lose work, with what to save before them
|
|
@@ -31,8 +55,10 @@ module Gitflash
|
|
|
31
55
|
rule = Rules.for(git.subcommand, git.args, git.dir)
|
|
32
56
|
next unless rule
|
|
33
57
|
|
|
34
|
-
|
|
35
|
-
|
|
58
|
+
dir = rule.fetch(:dir, git.dir)
|
|
59
|
+
Target.new(dir: dir.to_s, command: git.command, scope: rule[:scope],
|
|
60
|
+
branches: rule[:branches] || [], guessed: Paths.guessed?(dir),
|
|
61
|
+
ignored: rule.fetch(:ignored, false))
|
|
36
62
|
end
|
|
37
63
|
end
|
|
38
64
|
|
|
@@ -51,88 +77,85 @@ module Gitflash
|
|
|
51
77
|
GitCommand = Data.define(:dir, :command, :subcommand, :args)
|
|
52
78
|
|
|
53
79
|
def git_commands(command_line, dir = @cwd, depth = 0)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
next []
|
|
80
|
+
stack = []
|
|
81
|
+
ShellText.segments(command_line).flat_map do |segment|
|
|
82
|
+
words = CommandWords.new(segment)
|
|
83
|
+
if DIR_COMMANDS.include?(words.words.first)
|
|
84
|
+
dir = dir_after(words.words, dir, stack)
|
|
85
|
+
next substituted(segment, dir, depth)
|
|
61
86
|
end
|
|
62
|
-
|
|
87
|
+
substituted(segment, dir, depth) + in_segment(words, dir, segment.strip, depth)
|
|
63
88
|
end
|
|
64
89
|
end
|
|
65
90
|
|
|
66
|
-
#
|
|
67
|
-
|
|
68
|
-
|
|
91
|
+
# `cd DIR` and `pushd DIR` move, `pushd` remembering where it was; `popd` goes back there,
|
|
92
|
+
# or to the directory Claude runs in when the line pushed nothing
|
|
93
|
+
def dir_after(words, dir, stack)
|
|
94
|
+
return stack.pop || @cwd if words.first == 'popd'
|
|
69
95
|
|
|
70
|
-
|
|
71
|
-
|
|
96
|
+
stack.push(dir) if words.first == 'pushd'
|
|
97
|
+
change_dir(words[1] || Dir.home, dir)
|
|
72
98
|
end
|
|
73
99
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
return words.drop(1).join(' ') if words.first == 'eval'
|
|
77
|
-
return nil unless SHELLS.include?(File.basename(words.first))
|
|
100
|
+
def in_segment(words, dir, segment, depth)
|
|
101
|
+
return [] if words.words.empty?
|
|
78
102
|
|
|
79
|
-
|
|
80
|
-
|
|
103
|
+
dir = change_dir(words.chdir, dir) if words.chdir
|
|
104
|
+
commands_in(words.words, dir, segment, depth, words.env)
|
|
81
105
|
end
|
|
82
106
|
|
|
83
|
-
#
|
|
84
|
-
def
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
line.each_char do |char|
|
|
88
|
-
if quote
|
|
89
|
-
quote = nil if char == quote
|
|
90
|
-
elsif ["'", '"'].include?(char)
|
|
91
|
-
quote = char
|
|
92
|
-
elsif ";&|\n".include?(char)
|
|
93
|
-
parts << +''
|
|
94
|
-
next
|
|
95
|
-
end
|
|
96
|
-
parts.last << char
|
|
107
|
+
# Git commands inside `$(...)`, `<(...)` and backticks, which the shell runs first
|
|
108
|
+
def substituted(command_line, dir, depth)
|
|
109
|
+
ShellText.substitutions(command_line).flat_map do |script|
|
|
110
|
+
nested(script, dir, depth)
|
|
97
111
|
end
|
|
98
|
-
parts.reject { |part| part.strip.empty? }
|
|
99
112
|
end
|
|
100
113
|
|
|
101
|
-
#
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
114
|
+
# The git commands of a script inside this one. Past the nesting limit a script that
|
|
115
|
+
# mentions git counts as risky without being read, so deep nesting cannot hide a command.
|
|
116
|
+
def nested(script, dir, depth)
|
|
117
|
+
return git_commands(script, dir, depth + 1) if depth < MAX_NESTING
|
|
118
|
+
return [] unless script.match?(/\bgit\b/)
|
|
119
|
+
|
|
120
|
+
[GitCommand.new(dir: dir, command: script.strip, subcommand: Rules::UNREAD, args: [])]
|
|
106
121
|
end
|
|
107
122
|
|
|
108
|
-
|
|
109
|
-
|
|
123
|
+
# The git command of one segment, or the ones inside `bash -c "..."`, `eval "..."`,
|
|
124
|
+
# `find -exec` and `!` aliases
|
|
125
|
+
def commands_in(words, dir, segment, depth, env = {})
|
|
126
|
+
words = Wrappers.find_exec(words) || words
|
|
127
|
+
git = File.basename(words.first.to_s) == 'git'
|
|
128
|
+
return git_command(words, dir, segment, depth, env) if git
|
|
129
|
+
|
|
130
|
+
script = nested_script(words)
|
|
131
|
+
script ? nested(script, dir, depth) : []
|
|
110
132
|
end
|
|
111
133
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
134
|
+
# The script text passed to a shell (`bash -c`, `sh -lc`) or to `eval`
|
|
135
|
+
def nested_script(words)
|
|
136
|
+
return words.drop(1).join(' ') if words.first == 'eval'
|
|
137
|
+
return nil unless SHELLS.include?(File.basename(words.first))
|
|
138
|
+
|
|
139
|
+
index = words.index { |word| word.match?(/\A-[a-z]*c[a-z]*\z/) }
|
|
140
|
+
index && words[index + 1]
|
|
116
141
|
end
|
|
117
142
|
|
|
118
|
-
|
|
119
|
-
|
|
143
|
+
# The directory after `cd PATH` or `git -C PATH`. A path the shell decides when it runs
|
|
144
|
+
# (`cd "$(git rev-parse --show-toplevel)"`, `cd "$REPO"`, `cd -`) falls back to the
|
|
145
|
+
# directory Claude runs in: a snapshot there is better than none.
|
|
146
|
+
def change_dir(path, dir)
|
|
147
|
+
Paths.change_dir(path, dir, @cwd)
|
|
120
148
|
end
|
|
121
149
|
|
|
122
|
-
def git_command(words, dir, command)
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
150
|
+
def git_command(words, dir, command, depth, env)
|
|
151
|
+
git = @invocation.parse(words, dir, env)
|
|
152
|
+
return nested(git.script, git.dir, depth) if git.script
|
|
153
|
+
|
|
154
|
+
[GitCommand.new(dir: git.dir, command: command, subcommand: git.subcommand, args: git.args)]
|
|
126
155
|
end
|
|
127
156
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
index = 1
|
|
131
|
-
while (option = words[index])&.start_with?('-')
|
|
132
|
-
dir = File.expand_path(words[index + 1].to_s, dir) if option == '-C'
|
|
133
|
-
index += option == '-C' || OPTIONS_WITH_VALUE.include?(option) ? 2 : 1
|
|
134
|
-
end
|
|
135
|
-
[index, dir]
|
|
157
|
+
def aliases_in(dir)
|
|
158
|
+
@alias_cache[dir] ||= @aliases.call(dir)
|
|
136
159
|
end
|
|
137
160
|
end
|
|
138
161
|
end
|