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
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitflash
|
|
4
|
+
module Hook
|
|
5
|
+
# Reads a shell command line as text, without running it: where commands end, and which
|
|
6
|
+
# commands run inside `$(...)`, `<(...)` and backticks. Quotes are followed as the shell
|
|
7
|
+
# does: nothing runs inside single quotes, `$(...)` and backticks run inside double quotes,
|
|
8
|
+
# and a backslash keeps the next character (outside single quotes).
|
|
9
|
+
module ShellText
|
|
10
|
+
SEPARATORS = ";&|\n"
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# The command line split on ; & | and newlines outside quotes and substitutions
|
|
15
|
+
def segments(line)
|
|
16
|
+
scan(line).first
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The scripts the shell runs to build the line: the text inside `$(...)`, `<(...)`,
|
|
20
|
+
# `>(...)` and backticks. Nested ones are found when the caller reads each script in turn.
|
|
21
|
+
def substitutions(line)
|
|
22
|
+
scan(line).last
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# [segments, scripts] in one pass over the line
|
|
26
|
+
def scan(line)
|
|
27
|
+
parts = [+'']
|
|
28
|
+
scripts = []
|
|
29
|
+
index = 0
|
|
30
|
+
quote = nil
|
|
31
|
+
index, quote = step(line, index, quote, parts, scripts) while index < line.length
|
|
32
|
+
[parts.reject { |part| part.strip.empty? }, scripts]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Reads what starts at index and returns where to go on, with the quote then open
|
|
36
|
+
def step(line, index, quote, parts, scripts)
|
|
37
|
+
char = line[index]
|
|
38
|
+
return [index + 1, close_single(char, parts)] if quote == "'"
|
|
39
|
+
return take(line, index, index + 2, parts) + [quote] if char == '\\'
|
|
40
|
+
return [comment_end(line, index), nil] if quote.nil? && comment?(line, index)
|
|
41
|
+
|
|
42
|
+
substituted(line, index, quote, parts, scripts) || plain(char, index, quote, parts)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Takes a substitution that opens at index, or returns nil
|
|
46
|
+
def substituted(line, index, quote, parts, scripts)
|
|
47
|
+
script = substitution(line, index, quote)
|
|
48
|
+
return nil unless script
|
|
49
|
+
|
|
50
|
+
scripts << line[script.first...script.last]
|
|
51
|
+
take(line, index, script.last + 1, parts) + [quote]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# A `#` that starts a word outside quotes starts a comment, which runs to the end of the
|
|
55
|
+
# line; an apostrophe in it (`# don't`) must not open a quote
|
|
56
|
+
def comment?(line, index)
|
|
57
|
+
line[index] == '#' && (index.zero? || line[index - 1].match?(/[\s;&|()]/))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def comment_end(line, index)
|
|
61
|
+
line.index("\n", index) || line.length
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def close_single(char, parts)
|
|
65
|
+
parts.last << char
|
|
66
|
+
char == "'" ? nil : "'"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def plain(char, index, quote, parts)
|
|
70
|
+
if !quote && SEPARATORS.include?(char)
|
|
71
|
+
parts << +''
|
|
72
|
+
return [index + 1, nil]
|
|
73
|
+
end
|
|
74
|
+
parts.last << char
|
|
75
|
+
[index + 1, next_quote(char, quote)]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def next_quote(char, quote)
|
|
79
|
+
return (char == '"' ? nil : quote) if quote
|
|
80
|
+
return char if ["'", '"'].include?(char)
|
|
81
|
+
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def take(line, from, to, parts)
|
|
86
|
+
parts.last << line[from...to].to_s
|
|
87
|
+
[to]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# [script start, closing index] of a substitution opening at index, or nil. `<(...)` and
|
|
91
|
+
# `>(...)` only open outside quotes; `$(...)` and backticks also inside double quotes.
|
|
92
|
+
def substitution(line, index, quote)
|
|
93
|
+
if line[index] == '`'
|
|
94
|
+
[index + 1, ShellQuotes.closing_backtick(line, index + 1)]
|
|
95
|
+
elsif line[index, 2] == '$(' || (quote.nil? && line[index, 2].match?(/\A[<>]\(/))
|
|
96
|
+
[index + 2, ShellQuotes.closing_paren(line, index + 2)]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitflash
|
|
4
|
+
module Hook
|
|
5
|
+
# Commands that run the command after them: `sudo -u me git ...`, `timeout 5 git ...`,
|
|
6
|
+
# `xargs git ...`. Strips them, with their options, so the parser sees the git command.
|
|
7
|
+
module Wrappers
|
|
8
|
+
# Wrapper => its options that take a value as the next word
|
|
9
|
+
OPTIONS = {
|
|
10
|
+
'sudo' => %w[-u -g -h -p -C -D -r -t -U -T --user --group --host --prompt --chdir],
|
|
11
|
+
'env' => %w[-u -C -S --unset --chdir --split-string],
|
|
12
|
+
'command' => [], 'exec' => %w[-a], 'time' => %w[-f -o --format --output],
|
|
13
|
+
'nohup' => [], 'nice' => %w[-n --adjustment], 'ionice' => %w[-c -n -p -P -u],
|
|
14
|
+
'stdbuf' => %w[-i -o -e], 'chronic' => [], 'caffeinate' => %w[-t -w],
|
|
15
|
+
'timeout' => %w[-k -s --kill-after --signal],
|
|
16
|
+
# -i, -l and -e take only an attached value (`-i{}`), so they are not listed
|
|
17
|
+
'xargs' => %w[-I -n -P -L -d -E -s -a --arg-file --delimiter --max-args --max-procs]
|
|
18
|
+
}.freeze
|
|
19
|
+
# Wrappers whose first word after the options is not the command (`timeout 5 git ...`)
|
|
20
|
+
LEADING_ARGUMENT = %w[timeout].freeze
|
|
21
|
+
CHDIR_OPTIONS = %w[-C -D --chdir].freeze
|
|
22
|
+
FIND_EXEC = %w[-exec -execdir -ok -okdir].freeze
|
|
23
|
+
FIND_END = [';', '\;', '+'].freeze
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# The words without leading wrappers and their options, and the directory `sudo -D` or
|
|
28
|
+
# `env -C` switch to (nil when none does)
|
|
29
|
+
def strip(words)
|
|
30
|
+
chdir = nil
|
|
31
|
+
while (options = OPTIONS[File.basename(words.first.to_s)])
|
|
32
|
+
name = File.basename(words.first)
|
|
33
|
+
words, dir = drop_options(words.drop(1), options)
|
|
34
|
+
chdir = dir || chdir
|
|
35
|
+
words = words.drop(1) if LEADING_ARGUMENT.include?(name)
|
|
36
|
+
end
|
|
37
|
+
[words, chdir]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Drops options (and the values of those that take one) up to the command
|
|
41
|
+
def drop_options(words, with_value)
|
|
42
|
+
chdir = nil
|
|
43
|
+
while (word = words.first)&.start_with?('-')
|
|
44
|
+
words = words.drop(1)
|
|
45
|
+
break if word == '--'
|
|
46
|
+
next unless with_value.include?(word)
|
|
47
|
+
|
|
48
|
+
chdir = words.first if CHDIR_OPTIONS.include?(word)
|
|
49
|
+
words = words.drop(1)
|
|
50
|
+
end
|
|
51
|
+
[words, chdir]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The command `find ... -exec CMD ... ;` runs, or nil
|
|
55
|
+
def find_exec(words)
|
|
56
|
+
return nil unless File.basename(words.first.to_s) == 'find'
|
|
57
|
+
|
|
58
|
+
start = words.index { |word| FIND_EXEC.include?(word) }
|
|
59
|
+
return nil unless start
|
|
60
|
+
|
|
61
|
+
command = words[(start + 1)..].take_while { |word| !FIND_END.include?(word) }
|
|
62
|
+
.reject { |word| word == '{}' }
|
|
63
|
+
command.empty? ? nil : command
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/gitflash/hook_cli.rb
CHANGED
|
@@ -33,17 +33,33 @@ module Gitflash
|
|
|
33
33
|
branch NAME, worktree add) as agent work, so `gitflash clean --agent` can find them. A branch
|
|
34
34
|
is only marked when it is new (its reflog says it was created a moment ago) and unmarked.
|
|
35
35
|
|
|
36
|
-
Not meant to be run by hand: in a terminal it only prints a note. Errors
|
|
37
|
-
|
|
36
|
+
Not meant to be run by hand: in a terminal it only prints a note. Errors are printed to
|
|
37
|
+
stderr and the hook exits with 0. In snapshot mode the command then runs; in ask mode the
|
|
38
|
+
user is asked and in deny mode the command is blocked, so an error never turns the
|
|
39
|
+
protection off. If even that answer cannot be written, ask and deny mode exit with 2,
|
|
40
|
+
which makes Claude Code block the command.
|
|
38
41
|
TEXT
|
|
39
42
|
option :mode, type: :string, default: 'snapshot', enum: Hook::Claude::MODES
|
|
40
43
|
def claude
|
|
41
44
|
return puts(TERMINAL_NOTE) if $stdin.tty?
|
|
42
45
|
|
|
43
|
-
output =
|
|
46
|
+
output = hook_output(options[:mode], $stdin.read)
|
|
44
47
|
puts JSON.generate(output) if output
|
|
45
48
|
rescue StandardError => e
|
|
46
49
|
warn "gitflash hook: #{e.message}"
|
|
50
|
+
# No decision could be written: exit 2 makes Claude Code block the command in ask and
|
|
51
|
+
# deny mode instead of running it unprotected
|
|
52
|
+
exit 2 unless options[:mode] == 'snapshot'
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
no_commands do
|
|
56
|
+
# The hook's output; on an error, what Hook::Failure decides for the mode
|
|
57
|
+
def hook_output(mode, input)
|
|
58
|
+
Hook::Claude.new(mode: mode).call(input)
|
|
59
|
+
rescue StandardError => e
|
|
60
|
+
warn "gitflash hook: #{e.message}"
|
|
61
|
+
Hook::Failure.output(mode, e, input)
|
|
62
|
+
end
|
|
47
63
|
end
|
|
48
64
|
|
|
49
65
|
desc 'install', 'Register the gitflash hook in Claude Code settings'
|
|
@@ -59,13 +75,20 @@ module Gitflash
|
|
|
59
75
|
applies to sessions started there
|
|
60
76
|
--scope user ~/.claude/settings.json; applies to every session
|
|
61
77
|
|
|
78
|
+
--mode snapshot|ask|deny sets what the hook does (see `gitflash hook help claude`). Without
|
|
79
|
+
--mode an installed hook keeps its mode. A mode that protects less than the installed one
|
|
80
|
+
(deny, then ask, then snapshot) is refused unless you pass --downgrade.
|
|
81
|
+
|
|
62
82
|
Claude Code reloads settings files, so running sessions pick the hook up without a restart.
|
|
63
83
|
Check the result with `gitflash hook status`.
|
|
64
84
|
|
|
65
85
|
Accepts the global --json and --dry-run options after the subcommand: gitflash hook install --json
|
|
66
86
|
TEXT
|
|
67
87
|
option :scope, type: :string, default: 'local', enum: Commands::HookInstall::SCOPES
|
|
68
|
-
option :mode, type: :string,
|
|
88
|
+
option :mode, type: :string, enum: Hook::Claude::MODES,
|
|
89
|
+
desc: 'snapshot, ask or deny (default: the installed mode, else snapshot)'
|
|
90
|
+
option :downgrade, type: :boolean,
|
|
91
|
+
desc: 'Allow a --mode that protects less than the installed one'
|
|
69
92
|
def install
|
|
70
93
|
run_command('hook install', Commands::HookInstall, repo_required: options[:scope] != 'user')
|
|
71
94
|
end
|
data/lib/gitflash/protection.rb
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Gitflash
|
|
4
|
-
# Branches gitflash never deletes: the current and default branch,
|
|
5
|
-
#
|
|
4
|
+
# Branches gitflash never deletes: the current and default branch, the usual long-lived
|
|
5
|
+
# branches (`main`, `master`, `develop`, `development`, `trunk`, `release/*`), names matching
|
|
6
|
+
# `protected` in the settings, and any branch checked out in a worktree. The long-lived names
|
|
7
|
+
# count even when the default branch is something else or unknown: in a git-flow repository
|
|
8
|
+
# `develop` is merged into `main` after every release and would otherwise look done.
|
|
6
9
|
class Protection
|
|
7
|
-
NAMES = %w[main master].freeze
|
|
10
|
+
NAMES = %w[main master develop development trunk].freeze
|
|
11
|
+
PATTERNS = %w[release/*].freeze
|
|
8
12
|
|
|
9
13
|
def initialize(config:, checked_out: [])
|
|
10
14
|
@config = config
|
|
@@ -12,8 +16,12 @@ module Gitflash
|
|
|
12
16
|
end
|
|
13
17
|
|
|
14
18
|
def protected?(branch)
|
|
15
|
-
branch.current? || branch.default? ||
|
|
19
|
+
branch.current? || branch.default? || long_lived?(branch.name) ||
|
|
16
20
|
@config.protected?(branch.name) || @checked_out.include?(branch.name)
|
|
17
21
|
end
|
|
22
|
+
|
|
23
|
+
def long_lived?(name)
|
|
24
|
+
NAMES.include?(name) || PATTERNS.any? { |pattern| File.fnmatch?(pattern, name) }
|
|
25
|
+
end
|
|
18
26
|
end
|
|
19
27
|
end
|
data/lib/gitflash/snapshot.rb
CHANGED
|
@@ -12,9 +12,10 @@ module Gitflash
|
|
|
12
12
|
# worktree commit whose tree is the working tree including untracked files (not
|
|
13
13
|
# ignored ones), nil when the tree was clean
|
|
14
14
|
# skipped_files untracked files left out because they were too large
|
|
15
|
+
# excluded_files untracked files left out because `snapshot_exclude` matches them
|
|
15
16
|
Snapshot = Data.define(
|
|
16
17
|
:id, :created_at, :reason, :scope, :worktree_path, :head, :branches, :stashes,
|
|
17
|
-
:index, :worktree, :skipped_files
|
|
18
|
+
:index, :worktree, :skipped_files, :excluded_files
|
|
18
19
|
) do
|
|
19
20
|
def self.from_h(hash)
|
|
20
21
|
new(
|
|
@@ -22,10 +23,16 @@ module Gitflash
|
|
|
22
23
|
scope: hash['scope'], worktree_path: hash['worktree_path'],
|
|
23
24
|
head: { branch: hash.dig('head', 'branch'), sha: hash.dig('head', 'sha') },
|
|
24
25
|
branches: hash['branches'] || {}, stashes: hash['stashes'] || [],
|
|
25
|
-
index: hash['index'], worktree: hash['worktree'],
|
|
26
|
+
index: hash['index'], worktree: hash['worktree'],
|
|
27
|
+
skipped_files: hash['skipped_files'] || [], excluded_files: hash['excluded_files'] || []
|
|
26
28
|
)
|
|
27
29
|
end
|
|
28
30
|
|
|
31
|
+
# Untracked files this snapshot cannot bring back
|
|
32
|
+
def unsaved_files
|
|
33
|
+
skipped_files + excluded_files
|
|
34
|
+
end
|
|
35
|
+
|
|
29
36
|
def scope?(part)
|
|
30
37
|
scope.include?(part)
|
|
31
38
|
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitflash
|
|
4
|
+
# Checks a snapshot read from a ref before gitflash trusts it. Anyone with write access to the
|
|
5
|
+
# repository can create such a ref, and its fields go into git commands and paths.
|
|
6
|
+
module SnapshotCheck
|
|
7
|
+
SHA = /\A\h{40}(?:\h{24})?\z/
|
|
8
|
+
# What git accepts in a branch name, roughly: no control characters, spaces, `~^:?*[\`, `..`
|
|
9
|
+
# or `@{`, no leading `-` or `/`, no `//`, no trailing `/`, `.` or `.lock`
|
|
10
|
+
BRANCH = %r{\A(?!-|/)(?!.*(?:\.\.|@\{|//))[^\x00-\x20\x7F~^:?*\[\\]+(?<![/.])(?<!\.lock)\z}
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Why the snapshot read from ref cannot be trusted, or nil
|
|
15
|
+
def problem(ref, snapshot)
|
|
16
|
+
shape_problem(snapshot) || field_problem(ref, snapshot)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Fields of the wrong type, which the other checks could not even read
|
|
20
|
+
def shape_problem(snapshot)
|
|
21
|
+
return 'branches is not an object' unless snapshot.branches.is_a?(Hash)
|
|
22
|
+
return 'stashes is not a list' unless snapshot.stashes.is_a?(Array)
|
|
23
|
+
return 'scope is not a list of names' unless snapshot.scope.is_a?(Array) &&
|
|
24
|
+
snapshot.scope.all?(String)
|
|
25
|
+
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def field_problem(ref, snapshot)
|
|
30
|
+
expected = Snapshots::REF_PREFIX + snapshot.id.to_s
|
|
31
|
+
return 'its name does not match its id' unless ref == expected
|
|
32
|
+
return 'a commit id is not a full hex SHA' unless snapshot.object_ids.all? { |id| sha?(id) }
|
|
33
|
+
return 'a branch name is not valid' unless branch_names(snapshot).all? { |n| branch?(n) }
|
|
34
|
+
return 'worktree_path is not an absolute path' unless path?(snapshot.worktree_path)
|
|
35
|
+
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# BRANCH, and each slash-separated part non-empty, not starting with `.` and not ending in
|
|
40
|
+
# `.lock`; `@` alone is not a branch name either
|
|
41
|
+
def branch?(name)
|
|
42
|
+
name.match?(BRANCH) && name != '@' && name.split('/', -1).all? do |part|
|
|
43
|
+
!part.empty? && !part.start_with?('.') && !part.end_with?('.lock')
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def sha?(id)
|
|
48
|
+
id.is_a?(String) && id.match?(SHA)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def branch_names(snapshot)
|
|
52
|
+
(snapshot.branches.keys + [snapshot.head[:branch]]).compact.map(&:to_s)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# nil for snapshots from before worktree_path was recorded
|
|
56
|
+
def path?(path)
|
|
57
|
+
path.nil? || (path.is_a?(String) && path.start_with?('/') && !path.match?(/[\x00-\x1F]/))
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
data/lib/gitflash/snapshots.rb
CHANGED
|
@@ -31,11 +31,34 @@ module Gitflash
|
|
|
31
31
|
store(Snapshot.new(**identity, reason: reason, **state))
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
# Snapshots, newest first
|
|
34
|
+
# Snapshots, newest first. A ref whose message is not a snapshot is skipped with a warning,
|
|
35
|
+
# so one damaged ref cannot stop every command that lists or takes snapshots.
|
|
36
|
+
# Ordered by the time inside each snapshot, not by ref name, so a ref named to sort first
|
|
37
|
+
# cannot become the default for `undo`.
|
|
35
38
|
def list
|
|
36
|
-
git('for-each-ref', '--
|
|
39
|
+
git('for-each-ref', '--format=%(refname)%00%(contents:subject)', REF_PREFIX)
|
|
37
40
|
.each_line(chomp: true).reject(&:empty?)
|
|
38
|
-
.
|
|
41
|
+
.filter_map { |line| parse(*line.split("\0", 2)) }
|
|
42
|
+
.sort_by { |snapshot| [Time.iso8601(snapshot.created_at), snapshot.id] }.reverse
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def parse(ref, message)
|
|
46
|
+
data = JSON.parse(message.to_s)
|
|
47
|
+
raise TypeError, 'not an object' unless data.is_a?(Hash)
|
|
48
|
+
|
|
49
|
+
checked(ref, Snapshot.from_h(data))
|
|
50
|
+
rescue JSON::ParserError, TypeError, ArgumentError => e
|
|
51
|
+
warn "gitflash: skipping #{TerminalText.line(ref)}, which is not a readable snapshot " \
|
|
52
|
+
"(#{e.message.lines.first&.strip})"
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def checked(ref, snapshot)
|
|
57
|
+
Time.iso8601(snapshot.created_at.to_s)
|
|
58
|
+
problem = SnapshotCheck.problem(ref, snapshot)
|
|
59
|
+
raise TypeError, problem if problem
|
|
60
|
+
|
|
61
|
+
snapshot
|
|
39
62
|
end
|
|
40
63
|
|
|
41
64
|
def find(id)
|
|
@@ -18,8 +18,11 @@ module Gitflash
|
|
|
18
18
|
'GIT_AUTHOR_DATE' => '1970-01-01T00:00:00Z', 'GIT_COMMITTER_DATE' => '1970-01-01T00:00:00Z'
|
|
19
19
|
}.freeze
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
# `exclude` says whether an untracked path must stay out of snapshots; by default the
|
|
22
|
+
# `snapshot_exclude` setting of the repository decides
|
|
23
|
+
def initialize(bash: Git::BashCommand, exclude: nil)
|
|
22
24
|
@bash = bash
|
|
25
|
+
@exclude = exclude
|
|
23
26
|
end
|
|
24
27
|
|
|
25
28
|
# Snapshot fields except id, time and reason. `branch_names` nil records every branch.
|
|
@@ -30,7 +33,7 @@ module Gitflash
|
|
|
30
33
|
head: { branch: current_branch, sha: head_sha },
|
|
31
34
|
branches: scope.include?('branches') ? branch_tips(branch_names) : {},
|
|
32
35
|
stashes: scope.include?('stashes') ? try('stash', 'list', '--format=%H').to_s.split : [],
|
|
33
|
-
index: nil, worktree: nil, skipped_files: []
|
|
36
|
+
index: nil, worktree: nil, skipped_files: [], excluded_files: []
|
|
34
37
|
}
|
|
35
38
|
state.merge!(worktree(head_sha)) if scope.include?('worktree') && dirty?
|
|
36
39
|
state
|
|
@@ -80,11 +83,11 @@ module Gitflash
|
|
|
80
83
|
def worktree(head_sha)
|
|
81
84
|
parents = [head_sha].compact
|
|
82
85
|
index_tree = try('write-tree') # nil while a merge conflict is unresolved
|
|
83
|
-
tree, skipped = worktree_tree
|
|
86
|
+
tree, skipped, excluded = worktree_tree
|
|
84
87
|
{
|
|
85
88
|
index: index_tree && content_commit(index_tree, parents, 'gitflash index'),
|
|
86
89
|
worktree: content_commit(tree, parents, 'gitflash worktree'),
|
|
87
|
-
skipped_files: skipped
|
|
90
|
+
skipped_files: skipped, excluded_files: excluded
|
|
88
91
|
}
|
|
89
92
|
end
|
|
90
93
|
|
|
@@ -102,33 +105,27 @@ module Gitflash
|
|
|
102
105
|
env = { 'GIT_INDEX_FILE' => file.path }
|
|
103
106
|
|
|
104
107
|
git('-C', top, 'add', '--update', env: env)
|
|
105
|
-
skipped = add_untracked(top, env)
|
|
106
|
-
[git('write-tree', env: env), skipped]
|
|
108
|
+
skipped, excluded = add_untracked(top, env)
|
|
109
|
+
[git('write-tree', env: env), skipped, excluded]
|
|
107
110
|
end
|
|
108
111
|
end
|
|
109
112
|
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
def file_sizes(top, paths)
|
|
113
|
-
paths.filter_map do |path|
|
|
114
|
-
[path, File.lstat(File.join(top, path)).size]
|
|
115
|
-
rescue Errno::ENOENT
|
|
116
|
-
nil
|
|
117
|
-
end
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
# Adds untracked files that are not ignored; returns the ones skipped for their size
|
|
113
|
+
# Adds untracked files that are not ignored; returns [skipped for their size, excluded by
|
|
114
|
+
# snapshot_exclude]
|
|
121
115
|
def add_untracked(top, env)
|
|
122
116
|
paths = git('-C', top, 'ls-files', '-z', '--others', '--exclude-standard')
|
|
123
117
|
.split("\0").reject { |path| path.end_with?('/') }
|
|
124
|
-
|
|
125
|
-
small, large =
|
|
126
|
-
.map { |group| group.map(&:first) }
|
|
118
|
+
excluded, paths = paths.partition { |path| exclude.call(path) }
|
|
119
|
+
small, large = UntrackedFiles.split_by_size(top, paths, MAX_UNTRACKED_FILE_BYTES)
|
|
127
120
|
# File names are literal paths, never pathspec magic such as `:(exclude)*`
|
|
128
121
|
small.each_slice(ADD_BATCH_SIZE) do |batch|
|
|
129
122
|
git('--literal-pathspecs', '-C', top, 'add', '--', *batch, env: env)
|
|
130
123
|
end
|
|
131
|
-
large
|
|
124
|
+
[large, excluded]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def exclude
|
|
128
|
+
@exclude ||= Config.load(root: Repo.new(bash: @bash).main_root).method(:snapshot_excluded?)
|
|
132
129
|
end
|
|
133
130
|
end
|
|
134
131
|
end
|
data/lib/gitflash/ui.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'digest'
|
|
3
4
|
require 'json'
|
|
4
5
|
|
|
5
6
|
module Gitflash
|
|
@@ -12,11 +13,12 @@ module Gitflash
|
|
|
12
13
|
JSON_SCHEMA_VERSION = 1
|
|
13
14
|
OK_STATUSES = %w[done planned noop cancelled].freeze
|
|
14
15
|
|
|
15
|
-
def initialize(command:, json: false, yes: false, dry_run: false)
|
|
16
|
+
def initialize(command:, json: false, yes: false, dry_run: false, confirm: nil)
|
|
16
17
|
@command = command
|
|
17
18
|
@json = json
|
|
18
19
|
@yes = yes
|
|
19
20
|
@dry_run = dry_run
|
|
21
|
+
@confirm = confirm
|
|
20
22
|
end
|
|
21
23
|
|
|
22
24
|
def json?
|
|
@@ -46,7 +48,9 @@ module Gitflash
|
|
|
46
48
|
|
|
47
49
|
def error(error)
|
|
48
50
|
if json?
|
|
49
|
-
details = { code: error.code, message: error.message, exit_code: error.exit_code
|
|
51
|
+
details = { code: error.code, message: error.message, exit_code: error.exit_code,
|
|
52
|
+
confirm_token: error.respond_to?(:confirm_token) ? error.confirm_token : nil }
|
|
53
|
+
.compact
|
|
50
54
|
$stdout.puts JSON.generate(envelope(status: error.status, plan: error.plan, error: details))
|
|
51
55
|
else
|
|
52
56
|
warn TerminalText.safe(error.message)
|
|
@@ -63,7 +67,12 @@ module Gitflash
|
|
|
63
67
|
|
|
64
68
|
# True when the action may proceed: --yes was given or the user confirmed in a terminal.
|
|
65
69
|
# Raises ConfirmationRequired, carrying the plan, when nobody can confirm.
|
|
66
|
-
|
|
70
|
+
#
|
|
71
|
+
# An irreversible action (one `gitflash undo` cannot revert) is not covered by --yes: it
|
|
72
|
+
# needs --confirm with a token made from its plan, or the token typed in a terminal. The
|
|
73
|
+
# token changes with the plan, so a confirmation never carries over to a different change.
|
|
74
|
+
def confirm?(summary, plan:, irreversible: false)
|
|
75
|
+
return confirm_irreversible?(summary, plan) if irreversible
|
|
67
76
|
return true if @yes
|
|
68
77
|
|
|
69
78
|
message = "#{summary}\nRe-run with --yes to confirm."
|
|
@@ -72,8 +81,27 @@ module Gitflash
|
|
|
72
81
|
prompt.proceed_with_warning(TerminalText.safe(summary)) { true } == true
|
|
73
82
|
end
|
|
74
83
|
|
|
84
|
+
# The token that confirms this plan with --confirm
|
|
85
|
+
def self.confirm_token(plan)
|
|
86
|
+
Digest::SHA256.hexdigest(JSON.generate(plan))[0, 8]
|
|
87
|
+
end
|
|
88
|
+
|
|
75
89
|
private
|
|
76
90
|
|
|
91
|
+
def confirm_irreversible?(summary, plan)
|
|
92
|
+
token = self.class.confirm_token(plan)
|
|
93
|
+
return true if @confirm == token
|
|
94
|
+
|
|
95
|
+
summary = "#{summary}\ngitflash undo cannot revert this."
|
|
96
|
+
unless interactive?
|
|
97
|
+
message = "#{summary}\nRe-run with --confirm #{token} to confirm (--yes is not enough)."
|
|
98
|
+
raise ConfirmationRequired.new(message, plan: plan, confirm_token: token)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
warn TerminalText.safe(summary)
|
|
102
|
+
prompt.ask("Type #{token} to confirm:").to_s.strip == token
|
|
103
|
+
end
|
|
104
|
+
|
|
77
105
|
def envelope(status:, plan: nil, result: nil, error: nil, undo: nil)
|
|
78
106
|
{
|
|
79
107
|
schema: JSON_SCHEMA_VERSION,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitflash
|
|
4
|
+
# Sizes of untracked files, for StateCapture
|
|
5
|
+
module UntrackedFiles
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# [paths up to max bytes, larger paths]. A file removed since `ls-files` ran (a build
|
|
9
|
+
# writing temporary files) is left out, so `git add` is not given a path that is gone.
|
|
10
|
+
def split_by_size(top, paths, max)
|
|
11
|
+
sizes = paths.filter_map do |path|
|
|
12
|
+
[path, File.lstat(File.join(top, path)).size]
|
|
13
|
+
rescue Errno::ENOENT
|
|
14
|
+
nil
|
|
15
|
+
end
|
|
16
|
+
sizes.partition { |_path, size| size <= max }.map { |group| group.map(&:first) }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitflash
|
|
4
|
+
# Prints an error Thor raised while reading the command line (an unknown option, a value
|
|
5
|
+
# outside an enum, a missing argument) as gitflash's own errors are printed: in the JSON
|
|
6
|
+
# envelope with --json, else as text on stderr.
|
|
7
|
+
class UsageReport
|
|
8
|
+
TRUE_VALUES = %w[true TRUE t T].freeze
|
|
9
|
+
FALSE_VALUES = %w[false FALSE f F].freeze
|
|
10
|
+
|
|
11
|
+
# The command names the JSON envelope knows (schema/v1.json)
|
|
12
|
+
def self.commands
|
|
13
|
+
@commands ||= JSON.parse(File.read(Cli::SCHEMA_PATH)).dig('properties', 'command', 'enum')
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(args)
|
|
17
|
+
@args = args
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def print(error)
|
|
21
|
+
error = UsageError.new(error.message)
|
|
22
|
+
return Ui.new(command: command, json: true).error(error) if json?
|
|
23
|
+
|
|
24
|
+
warn TerminalText.safe(error.message)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# Whether the command line asks for JSON, read as Thor reads a boolean: `--json`,
|
|
30
|
+
# `--json=t` and the like, `--json false`; the last one given wins
|
|
31
|
+
def json?
|
|
32
|
+
@args.each_with_index.reduce(false) do |json, (arg, index)|
|
|
33
|
+
if arg == '--json' then !FALSE_VALUES.include?(@args[index + 1])
|
|
34
|
+
elsif arg.start_with?('--json=') then TRUE_VALUES.include?(arg.delete_prefix('--json='))
|
|
35
|
+
elsif arg == '--no-json' then false
|
|
36
|
+
else json
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The command as the envelope names it: `wt` and `worktree` subcommands as
|
|
42
|
+
# `worktree SUBCOMMAND`, `hook SUBCOMMAND`, else the first word; `gitflash` when the line
|
|
43
|
+
# names no known command
|
|
44
|
+
def command
|
|
45
|
+
words = @args.reject { |arg| arg.start_with?('-') }
|
|
46
|
+
first = resolve(Cli, words.first.to_s)
|
|
47
|
+
first = 'worktree' if first == 'wt'
|
|
48
|
+
name = case first
|
|
49
|
+
when 'worktree' then "worktree #{resolve(WorktreeCli, words[1].to_s)}"
|
|
50
|
+
when 'hook' then "hook #{resolve(HookCli, words[1].to_s)}"
|
|
51
|
+
else first
|
|
52
|
+
end
|
|
53
|
+
self.class.commands.include?(name) ? name : 'gitflash'
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The command Thor runs for a word: itself, or the one command it is an unambiguous prefix
|
|
57
|
+
# of (Thor runs `mar` as `mark`)
|
|
58
|
+
def resolve(klass, word)
|
|
59
|
+
names = klass.all_commands.keys.map { |key| key.tr('_', '-') }
|
|
60
|
+
return word if names.include?(word)
|
|
61
|
+
|
|
62
|
+
matches = names.select { |key| key.start_with?(word) }
|
|
63
|
+
matches.one? && !word.empty? ? matches.first : word
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
data/lib/gitflash/version.rb
CHANGED