gitflash 0.7.0.beta1 → 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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -0
  3. data/README.md +17 -13
  4. data/command_descriptions.yml +25 -7
  5. data/lib/gitflash/atomic_file.rb +40 -0
  6. data/lib/gitflash/cherry_pick/log_parser.rb +63 -0
  7. data/lib/gitflash/cherry_pick.rb +10 -18
  8. data/lib/gitflash/cli.rb +28 -5
  9. data/lib/gitflash/command_runner.rb +12 -1
  10. data/lib/gitflash/commands/base.rb +3 -0
  11. data/lib/gitflash/commands/clean.rb +29 -1
  12. data/lib/gitflash/commands/gc.rb +19 -4
  13. data/lib/gitflash/commands/hook_install.rb +79 -12
  14. data/lib/gitflash/commands/mark.rb +9 -3
  15. data/lib/gitflash/commands/pick.rb +13 -1
  16. data/lib/gitflash/commands/pick_control.rb +1 -1
  17. data/lib/gitflash/commands/removal_report.rb +8 -3
  18. data/lib/gitflash/commands/snapshot_create.rb +6 -4
  19. data/lib/gitflash/commands/undo.rb +24 -11
  20. data/lib/gitflash/commands/worktree_add.rb +6 -4
  21. data/lib/gitflash/commands/worktree_command.rb +17 -3
  22. data/lib/gitflash/commands/worktree_list.rb +3 -3
  23. data/lib/gitflash/commands/worktree_lock.rb +7 -3
  24. data/lib/gitflash/commands/worktree_move.rb +9 -4
  25. data/lib/gitflash/commands/worktree_prune.rb +1 -1
  26. data/lib/gitflash/commands/worktree_remove.rb +23 -7
  27. data/lib/gitflash/config.rb +69 -16
  28. data/lib/gitflash/config_keys.rb +64 -4
  29. data/lib/gitflash/confirmation_required.rb +6 -2
  30. data/lib/gitflash/git/bash_command.rb +5 -2
  31. data/lib/gitflash/hook/branch_marker.rb +109 -6
  32. data/lib/gitflash/hook/claude.rb +45 -24
  33. data/lib/gitflash/hook/command_parser.rb +100 -59
  34. data/lib/gitflash/hook/command_words.rb +73 -0
  35. data/lib/gitflash/hook/failure.rb +29 -0
  36. data/lib/gitflash/hook/git_invocation.rb +119 -0
  37. data/lib/gitflash/hook/history_rules.rb +81 -0
  38. data/lib/gitflash/hook/losses.rb +51 -0
  39. data/lib/gitflash/hook/messages.rb +64 -0
  40. data/lib/gitflash/hook/paths.rb +47 -0
  41. data/lib/gitflash/hook/rules.rb +36 -12
  42. data/lib/gitflash/hook/settings.rb +19 -2
  43. data/lib/gitflash/hook/shell_quotes.rb +64 -0
  44. data/lib/gitflash/hook/shell_text.rb +101 -0
  45. data/lib/gitflash/hook/wrappers.rb +67 -0
  46. data/lib/gitflash/hook_cli.rb +34 -4
  47. data/lib/gitflash/protection.rb +12 -4
  48. data/lib/gitflash/repo.rb +3 -2
  49. data/lib/gitflash/snapshot.rb +9 -2
  50. data/lib/gitflash/snapshot_check.rb +60 -0
  51. data/lib/gitflash/snapshots.rb +26 -3
  52. data/lib/gitflash/state_capture.rb +26 -24
  53. data/lib/gitflash/terminal_text.rb +9 -0
  54. data/lib/gitflash/ui.rb +32 -4
  55. data/lib/gitflash/untracked_files.rb +19 -0
  56. data/lib/gitflash/usage_report.rb +66 -0
  57. data/lib/gitflash/version.rb +1 -1
  58. data/lib/gitflash/worktree.rb +4 -4
  59. data/schema/v1.json +39 -2
  60. metadata +16 -1
@@ -7,10 +7,15 @@ module Gitflash
7
7
  # { scope:, branches:, dir: } where branches may contain :current for the checked-out one.
8
8
  module Rules
9
9
  FULL = %w[branches head worktree].freeze
10
+ # A command too deeply nested to read (see CommandParser#nested): save everything
11
+ UNREAD = :unread
10
12
 
11
13
  module_function
12
14
 
13
15
  def for(subcommand, args, dir)
16
+ # No branch names: the snapshot keeps every branch
17
+ return { scope: FULL } if subcommand == UNREAD
18
+
14
19
  rule = RULES[subcommand]
15
20
  rule&.call(args, dir)
16
21
  end
@@ -34,16 +39,27 @@ module Gitflash
34
39
 
35
40
  def path_arg?(args, dir)
36
41
  args.reject { |arg| arg.start_with?('-') }.any? do |arg|
37
- File.exist?(File.expand_path(arg, dir))
42
+ File.exist?(Paths.expand(arg, dir))
38
43
  end
39
44
  end
40
45
 
41
- def clean(args, _dir)
46
+ # `git clean` without -f removes files too when clean.requireForce is false
47
+ def clean(args, dir)
42
48
  flags = args.select { |arg| arg.start_with?('-') }
43
49
  short = short_flags(args)
44
- force = flags.include?('--force') || short.include?('f')
45
- dry_run = any?(flags, %w[--dry-run --interactive]) || short.match?(/[ni]/)
46
- force && !dry_run ? { scope: %w[worktree] } : nil
50
+ removes = any?(flags, %w[--force --interactive]) || short.match?(/[fi]/) ||
51
+ !require_force?(dir)
52
+ dry_run = flags.include?('--dry-run') || short.include?('n')
53
+ return nil unless removes && !dry_run
54
+
55
+ # -x and -X delete ignored files too
56
+ { scope: %w[worktree], ignored: short.match?(/[xX]/) }
57
+ end
58
+
59
+ def require_force?(dir)
60
+ stdout, _stderr, success = Git::BashCommand.capture('git', '-C', dir, 'config', '--bool',
61
+ 'clean.requireForce')
62
+ !(success && stdout.strip == 'false')
47
63
  end
48
64
 
49
65
  def switch(args, _dir)
@@ -59,12 +75,19 @@ module Gitflash
59
75
  end
60
76
 
61
77
  def branch(args, _dir)
62
- return nil unless any?(args, %w[-d -D --delete -f --force -m -M --move -C --copy])
63
-
64
- { scope: %w[branches], branches: args.reject { |arg| arg.start_with?('-') } }
78
+ changes = any?(args, %w[--delete --force --move --copy]) ||
79
+ short_flags(args).match?(/[dDfmMcC]/)
80
+ return nil unless changes
81
+
82
+ names = args.reject { |arg| arg.start_with?('-') }
83
+ # A name the shell or xargs fills in (`$b`, `{}`) is unknown: save every branch
84
+ names = [] if names.any? { |name| Paths.unresolved?(name) || name.include?('{}') }
85
+ { scope: %w[branches], branches: names }
65
86
  end
66
87
 
67
88
  def update_ref(args, _dir)
89
+ # `--stdin` reads the refs to delete from input the text does not show: save all branches
90
+ return { scope: %w[branches] } if args.include?('--stdin')
68
91
  return nil unless args.include?('-d')
69
92
 
70
93
  heads = args.select { |arg| arg.start_with?('refs/heads/') }
@@ -79,12 +102,13 @@ module Gitflash
79
102
  any?(args, %w[--abort --quit]) ? { scope: %w[worktree] } : nil
80
103
  end
81
104
 
82
- # `git worktree remove --force PATH` deletes that worktree's uncommitted files
105
+ # `git worktree remove PATH` deletes that worktree's ignored files, and with --force its
106
+ # uncommitted ones too
83
107
  def worktree(args, dir)
84
- return nil unless args.first == 'remove' && any?(args, %w[-f --force])
108
+ return nil unless args.first == 'remove'
85
109
 
86
110
  path = args[1..].reject { |arg| arg.start_with?('-') }.first
87
- path && { scope: %w[worktree], dir: File.expand_path(path, dir) }
111
+ path && { scope: %w[worktree], dir: Paths.change_dir(path, dir, dir), ignored: true }
88
112
  end
89
113
 
90
114
  RULES = {
@@ -102,7 +126,7 @@ module Gitflash
102
126
  'revert' => method(:abort),
103
127
  'am' => method(:abort),
104
128
  'worktree' => method(:worktree)
105
- }.freeze
129
+ }.merge(HistoryRules::RULES).freeze
106
130
  end
107
131
  end
108
132
  end
@@ -44,16 +44,33 @@ module Gitflash
44
44
  def read(path)
45
45
  return {} unless File.exist?(path)
46
46
 
47
- JSON.parse(File.read(path))
47
+ check_shape!(path, JSON.parse(File.read(path)))
48
48
  rescue JSON::ParserError => e
49
49
  raise Error.new("#{path} is not valid JSON: #{e.message}", code: 'invalid_settings')
50
+ rescue SystemCallError => e
51
+ raise Error.new("#{path} cannot be read: #{e.message}", code: 'invalid_settings')
52
+ end
53
+
54
+ def check_shape!(path, data)
55
+ # A missing `hooks` key is a file without hooks yet; a present one must be an object
56
+ # whose events are lists of objects, as Claude Code reads them
57
+ return data if data.is_a?(Hash) && (!data.key?('hooks') || hooks_shape?(data['hooks']))
58
+
59
+ raise Error.new("#{path} does not hold a JSON object whose `hooks` maps each event to " \
60
+ 'a list of objects', code: 'invalid_settings')
61
+ end
62
+
63
+ def hooks_shape?(hooks)
64
+ hooks.is_a?(Hash) &&
65
+ hooks.values.all? { |groups| groups.is_a?(Array) && groups.all?(Hash) }
50
66
  end
51
67
 
52
68
  # The gitflash hook entry for an event (PreToolUse by default) in parsed settings, or nil.
53
69
  # Only groups whose matcher covers the Bash tool count: a gitflash command under another
54
70
  # matcher (Edit, say) never runs for Bash commands.
55
71
  def entry(settings, event = 'PreToolUse')
56
- groups = Array(settings.dig('hooks', event))
72
+ hooks = settings['hooks']
73
+ groups = hooks.is_a?(Hash) ? Array(hooks[event]) : []
57
74
  bash = groups.select { |group| bash_matcher?(group['matcher']) }
58
75
  bash.flat_map { |group| Array(group['hooks']) }
59
76
  .find { |hook| hook['command'].to_s.match?(PATTERN) }
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ module Hook
5
+ # Where quoted text and substitutions end in a shell command line (see ShellText)
6
+ module ShellQuotes
7
+ module_function
8
+
9
+ # The first backtick at or after index that no backslash escapes, or the end of the line
10
+ def closing_backtick(line, index)
11
+ while index < line.length
12
+ return index if line[index] == '`'
13
+
14
+ index += line[index] == '\\' ? 2 : 1
15
+ end
16
+ line.length
17
+ end
18
+
19
+ # The `)` that closes a parenthesis opened before index, skipping quoted text and nested
20
+ # substitutions and subshells, or the end of the line
21
+ def closing_paren(line, index)
22
+ while index < line.length
23
+ return index if line[index] == ')'
24
+
25
+ index = after_in_parens(line, index)
26
+ end
27
+ line.length
28
+ end
29
+
30
+ def after_in_parens(line, index)
31
+ case line[index]
32
+ when '\\' then index + 2
33
+ when "'" then closing_single(line, index + 1) + 1
34
+ when '"' then closing_double(line, index + 1) + 1
35
+ when '`' then closing_backtick(line, index + 1) + 1
36
+ when '(' then closing_paren(line, index + 1) + 1
37
+ else index + 1
38
+ end
39
+ end
40
+
41
+ def closing_single(line, index)
42
+ line.index("'", index) || line.length
43
+ end
44
+
45
+ # The `"` that closes a double-quoted string opened before index
46
+ def closing_double(line, index)
47
+ while index < line.length
48
+ return index if line[index] == '"'
49
+
50
+ index = after_in_double(line, index)
51
+ end
52
+ line.length
53
+ end
54
+
55
+ def after_in_double(line, index)
56
+ return index + 2 if line[index] == '\\'
57
+ return closing_backtick(line, index + 1) + 1 if line[index] == '`'
58
+ return closing_paren(line, index + 2) + 1 if line[index, 2] == '$('
59
+
60
+ index + 1
61
+ end
62
+ end
63
+ end
64
+ end
@@ -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
@@ -14,8 +14,12 @@ module Gitflash
14
14
  true
15
15
  end
16
16
 
17
+ TERMINAL_NOTE = 'gitflash hook claude is run by Claude Code, not by hand. Use ' \
18
+ '`gitflash hook install` to set it up and `gitflash hook status` to check it.'
19
+
17
20
  desc 'claude',
18
- 'Run as a Claude Code hook (PreToolUse and PostToolUse; reads the input on stdin)'
21
+ 'Run as a Claude Code hook (PreToolUse and PostToolUse; reads the input on stdin)',
22
+ hide: true
19
23
  long_desc <<~TEXT
20
24
  Reads the Claude Code hook input on stdin. When the Bash command can discard work git
21
25
  cannot restore (for example reset --hard, clean -f, checkout -- ., branch -D, stash drop),
@@ -29,14 +33,33 @@ module Gitflash
29
33
  branch NAME, worktree add) as agent work, so `gitflash clean --agent` can find them. A branch
30
34
  is only marked when it is new (its reflog says it was created a moment ago) and unmarked.
31
35
 
32
- Errors never block the command: they are printed to stderr and the hook exits with 0.
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.
33
41
  TEXT
34
42
  option :mode, type: :string, default: 'snapshot', enum: Hook::Claude::MODES
35
43
  def claude
36
- output = Hook::Claude.new(mode: options[:mode]).call($stdin.read)
44
+ return puts(TERMINAL_NOTE) if $stdin.tty?
45
+
46
+ output = hook_output(options[:mode], $stdin.read)
37
47
  puts JSON.generate(output) if output
38
48
  rescue StandardError => e
39
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
40
63
  end
41
64
 
42
65
  desc 'install', 'Register the gitflash hook in Claude Code settings'
@@ -52,13 +75,20 @@ module Gitflash
52
75
  applies to sessions started there
53
76
  --scope user ~/.claude/settings.json; applies to every session
54
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
+
55
82
  Claude Code reloads settings files, so running sessions pick the hook up without a restart.
56
83
  Check the result with `gitflash hook status`.
57
84
 
58
85
  Accepts the global --json and --dry-run options after the subcommand: gitflash hook install --json
59
86
  TEXT
60
87
  option :scope, type: :string, default: 'local', enum: Commands::HookInstall::SCOPES
61
- option :mode, type: :string, default: 'snapshot', enum: Hook::Claude::MODES
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'
62
92
  def install
63
93
  run_command('hook install', Commands::HookInstall, repo_required: options[:scope] != 'user')
64
94
  end
@@ -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, `main`, `master`, names
5
- # matching `protected` in the settings, and any branch checked out in a worktree.
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? || NAMES.include?(branch.name) ||
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/repo.rb CHANGED
@@ -39,12 +39,13 @@ module Gitflash
39
39
  end
40
40
 
41
41
  def toplevel
42
- @bash.exec('git', 'rev-parse', '--show-toplevel').strip
42
+ @bash.exec('git', 'rev-parse', '--show-toplevel').delete_suffix("\n")
43
43
  end
44
44
 
45
45
  # Root of the main checkout; differs from toplevel inside a linked worktree
46
46
  def main_root
47
- common = @bash.exec('git', 'rev-parse', '--path-format=absolute', '--git-common-dir').strip
47
+ common = @bash.exec('git', 'rev-parse', '--path-format=absolute',
48
+ '--git-common-dir').delete_suffix("\n")
48
49
  File.basename(common) == '.git' ? File.dirname(common) : toplevel
49
50
  end
50
51
 
@@ -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'], skipped_files: hash['skipped_files'] || []
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
@@ -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', '--sort=-refname', '--format=%(contents:subject)', REF_PREFIX)
39
+ git('for-each-ref', '--format=%(refname)%00%(contents:subject)', REF_PREFIX)
37
40
  .each_line(chomp: true).reject(&:empty?)
38
- .map { |line| Snapshot.from_h(JSON.parse(line)) }
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)