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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +15 -11
  4. data/command_descriptions.yml +21 -7
  5. data/lib/gitflash/atomic_file.rb +40 -0
  6. data/lib/gitflash/cli.rb +27 -4
  7. data/lib/gitflash/command_runner.rb +12 -1
  8. data/lib/gitflash/commands/clean.rb +29 -1
  9. data/lib/gitflash/commands/gc.rb +19 -4
  10. data/lib/gitflash/commands/hook_install.rb +66 -12
  11. data/lib/gitflash/commands/mark.rb +9 -3
  12. data/lib/gitflash/commands/snapshot_create.rb +6 -4
  13. data/lib/gitflash/commands/undo.rb +18 -8
  14. data/lib/gitflash/commands/worktree_command.rb +17 -3
  15. data/lib/gitflash/commands/worktree_remove.rb +14 -5
  16. data/lib/gitflash/config.rb +63 -13
  17. data/lib/gitflash/config_keys.rb +52 -4
  18. data/lib/gitflash/confirmation_required.rb +6 -2
  19. data/lib/gitflash/hook/claude.rb +17 -12
  20. data/lib/gitflash/hook/command_parser.rb +91 -68
  21. data/lib/gitflash/hook/command_words.rb +73 -0
  22. data/lib/gitflash/hook/failure.rb +29 -0
  23. data/lib/gitflash/hook/git_invocation.rb +119 -0
  24. data/lib/gitflash/hook/history_rules.rb +81 -0
  25. data/lib/gitflash/hook/losses.rb +51 -0
  26. data/lib/gitflash/hook/messages.rb +28 -2
  27. data/lib/gitflash/hook/paths.rb +47 -0
  28. data/lib/gitflash/hook/rules.rb +31 -9
  29. data/lib/gitflash/hook/settings.rb +19 -2
  30. data/lib/gitflash/hook/shell_quotes.rb +64 -0
  31. data/lib/gitflash/hook/shell_text.rb +101 -0
  32. data/lib/gitflash/hook/wrappers.rb +67 -0
  33. data/lib/gitflash/hook_cli.rb +27 -4
  34. data/lib/gitflash/protection.rb +12 -4
  35. data/lib/gitflash/snapshot.rb +9 -2
  36. data/lib/gitflash/snapshot_check.rb +60 -0
  37. data/lib/gitflash/snapshots.rb +26 -3
  38. data/lib/gitflash/state_capture.rb +18 -21
  39. data/lib/gitflash/ui.rb +31 -3
  40. data/lib/gitflash/untracked_files.rb +19 -0
  41. data/lib/gitflash/usage_report.rb +66 -0
  42. data/lib/gitflash/version.rb +1 -1
  43. data/schema/v1.json +39 -2
  44. 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
@@ -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 never block the
37
- 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.
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 = Hook::Claude.new(mode: options[:mode]).call($stdin.read)
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, 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'
69
92
  def install
70
93
  run_command('hook install', Commands::HookInstall, repo_required: options[:scope] != 'user')
71
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
@@ -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)
@@ -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
- def initialize(bash: Git::BashCommand)
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
- # [path, size] pairs. A file removed since `ls-files` ran (a build writing temporary files)
111
- # is left out, so `git add` is not given a path that is gone.
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
- sizes = file_sizes(top, paths)
125
- small, large = sizes.partition { |_path, size| size <= MAX_UNTRACKED_FILE_BYTES }
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
- def confirm?(summary, plan:)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gitflash
4
- VERSION = '0.7.0.beta2'
4
+ VERSION = '0.7.0.beta3'
5
5
  end