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
@@ -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
@@ -48,13 +51,15 @@ module Gitflash
48
51
  private
49
52
 
50
53
  def git(*, env: {})
51
- @bash.exec('git', *, env: env).strip
54
+ # Only git's final newline goes: a path may start or end with whitespace, even a carriage
55
+ # return, which `chomp` would take together with the newline
56
+ @bash.exec('git', *, env: env).delete_suffix("\n")
52
57
  end
53
58
 
54
59
  # Stripped stdout, or nil when the command fails
55
60
  def try(*)
56
61
  stdout, _stderr, success = @bash.capture('git', *)
57
- success ? stdout.strip : nil
62
+ success ? stdout.delete_suffix("\n") : nil
58
63
  end
59
64
 
60
65
  def rev(ref)
@@ -78,11 +83,11 @@ module Gitflash
78
83
  def worktree(head_sha)
79
84
  parents = [head_sha].compact
80
85
  index_tree = try('write-tree') # nil while a merge conflict is unresolved
81
- tree, skipped = worktree_tree
86
+ tree, skipped, excluded = worktree_tree
82
87
  {
83
88
  index: index_tree && content_commit(index_tree, parents, 'gitflash index'),
84
89
  worktree: content_commit(tree, parents, 'gitflash worktree'),
85
- skipped_files: skipped
90
+ skipped_files: skipped, excluded_files: excluded
86
91
  }
87
92
  end
88
93
 
@@ -100,30 +105,27 @@ module Gitflash
100
105
  env = { 'GIT_INDEX_FILE' => file.path }
101
106
 
102
107
  git('-C', top, 'add', '--update', env: env)
103
- skipped = add_untracked(top, env)
104
- [git('write-tree', env: env), skipped]
108
+ skipped, excluded = add_untracked(top, env)
109
+ [git('write-tree', env: env), skipped, excluded]
105
110
  end
106
111
  end
107
112
 
108
- # [path, size] pairs. A file removed since `ls-files` ran (a build writing temporary files)
109
- # is left out, so `git add` is not given a path that is gone.
110
- def file_sizes(top, paths)
111
- paths.filter_map do |path|
112
- [path, File.lstat(File.join(top, path)).size]
113
- rescue Errno::ENOENT
114
- nil
115
- end
116
- end
117
-
118
- # 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]
119
115
  def add_untracked(top, env)
120
116
  paths = git('-C', top, 'ls-files', '-z', '--others', '--exclude-standard')
121
117
  .split("\0").reject { |path| path.end_with?('/') }
122
- sizes = file_sizes(top, paths)
123
- small, large = sizes.partition { |_path, size| size <= MAX_UNTRACKED_FILE_BYTES }
124
- .map { |group| group.map(&:first) }
125
- small.each_slice(ADD_BATCH_SIZE) { |batch| git('-C', top, 'add', '--', *batch, env: env) }
126
- large
118
+ excluded, paths = paths.partition { |path| exclude.call(path) }
119
+ small, large = UntrackedFiles.split_by_size(top, paths, MAX_UNTRACKED_FILE_BYTES)
120
+ # File names are literal paths, never pathspec magic such as `:(exclude)*`
121
+ small.each_slice(ADD_BATCH_SIZE) do |batch|
122
+ git('--literal-pathspecs', '-C', top, 'add', '--', *batch, env: env)
123
+ end
124
+ [large, excluded]
125
+ end
126
+
127
+ def exclude
128
+ @exclude ||= Config.load(root: Repo.new(bash: @bash).main_root).method(:snapshot_excluded?)
127
129
  end
128
130
  end
129
131
  end
@@ -15,5 +15,14 @@ module Gitflash
15
15
  format(char.ord > 0xFF ? '\\u%04X' : '\\x%02X', char.ord)
16
16
  end
17
17
  end
18
+
19
+ # Text that must stay on one line, such as a path inside a bullet list: as `safe`, and a
20
+ # newline or tab in it is shown as an escape too, so it cannot start a fake row. A literal
21
+ # backslash is doubled first, so two different texts never look the same (a real newline
22
+ # shows as `\n`, the two characters backslash and n as `\\n`); that keeps menu labels unique.
23
+ def line(text)
24
+ doubled = text.to_s.scrub.gsub('\\') { '\\\\' }
25
+ safe(doubled).gsub("\n") { '\\n' }.gsub("\t") { '\\t' }
26
+ end
18
27
  end
19
28
  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,17 +67,41 @@ 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."
70
79
  raise ConfirmationRequired.new(message, plan: plan) unless interactive?
71
80
 
72
- prompt.proceed_with_warning(summary) { true } == true
81
+ prompt.proceed_with_warning(TerminalText.safe(summary)) { true } == true
82
+ end
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]
73
87
  end
74
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.beta1'
4
+ VERSION = '0.7.0.beta3'
5
5
  end
@@ -17,16 +17,16 @@ module Gitflash
17
17
  # Why this worktree must not be removed, nil when it can go. `current` is true for the
18
18
  # worktree gitflash runs in. The main checkout and the current one are never removable.
19
19
  def removal_blocker(force:, current:)
20
- return "#{path} is the main checkout" if main?
21
- return "#{path} is the current worktree" if current
20
+ return "#{TerminalText.line(path)} is the main checkout" if main?
21
+ return "#{TerminalText.line(path)} is the current worktree" if current
22
22
 
23
23
  in_use_blocker unless force || (missing? && !locked?)
24
24
  end
25
25
 
26
26
  def in_use_blocker
27
- return "#{path} is locked (use --force)" if locked?
27
+ return "#{TerminalText.line(path)} is locked (use --force)" if locked?
28
28
 
29
- "#{path} has uncommitted changes (use --force)" if dirty? != false
29
+ "#{TerminalText.line(path)} has uncommitted changes (use --force)" if dirty? != false
30
30
  end
31
31
  end
32
32
  end
data/schema/v1.json CHANGED
@@ -37,8 +37,10 @@
37
37
  "clean",
38
38
  "worktree clean",
39
39
  "pick",
40
- "mark"
41
- ]
40
+ "mark",
41
+ "gitflash"
42
+ ],
43
+ "description": "`gitflash` when the command line names no known command (an invalid_usage error)."
42
44
  },
43
45
  "ok": {
44
46
  "type": "boolean"
@@ -575,6 +577,10 @@
575
577
  1,
576
578
  2
577
579
  ]
580
+ },
581
+ "confirm_token": {
582
+ "type": "string",
583
+ "description": "Only for a change gitflash undo cannot revert: re-run with --confirm TOKEN to make it. --yes is not enough. The token belongs to this plan."
578
584
  }
579
585
  }
580
586
  },
@@ -908,6 +914,13 @@
908
914
  "type": "string"
909
915
  },
910
916
  "description": "Untracked files not saved because they are larger than 50 MB."
917
+ },
918
+ "excluded_files": {
919
+ "type": "array",
920
+ "items": {
921
+ "type": "string"
922
+ },
923
+ "description": "Untracked files not saved because the snapshot_exclude setting matches them."
911
924
  }
912
925
  }
913
926
  },
@@ -924,6 +937,10 @@
924
937
  "snapshot": {
925
938
  "type": "string"
926
939
  },
940
+ "reason": {
941
+ "type": "string",
942
+ "description": "Why the snapshot was taken, such as `agent hook: before ...`; check it before confirming."
943
+ },
927
944
  "branches": {
928
945
  "type": "array",
929
946
  "items": {
@@ -1093,6 +1110,22 @@
1093
1110
  "marking_command": {
1094
1111
  "type": "string",
1095
1112
  "description": "The PostToolUse command that marks branches an agent creates as agent work."
1113
+ },
1114
+ "mode": {
1115
+ "enum": [
1116
+ "snapshot",
1117
+ "ask",
1118
+ "deny"
1119
+ ],
1120
+ "description": "The mode the PreToolUse hook runs in after this install."
1121
+ },
1122
+ "previous_mode": {
1123
+ "enum": [
1124
+ "snapshot",
1125
+ "ask",
1126
+ "deny"
1127
+ ],
1128
+ "description": "The mode the installed hook had, when this install changes it."
1096
1129
  }
1097
1130
  }
1098
1131
  },
@@ -1592,6 +1625,10 @@
1592
1625
  "skipped"
1593
1626
  ],
1594
1627
  "properties": {
1628
+ "warning": {
1629
+ "type": "string",
1630
+ "description": "Set when the repository has an origin remote but no origin/HEAD: the default branch, and so what counts as merged, was guessed."
1631
+ },
1595
1632
  "branches": {
1596
1633
  "type": "array",
1597
1634
  "items": {
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gitflash
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0.beta1
4
+ version: 0.7.0.beta3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kleanthis Mitsioulis
@@ -73,8 +73,10 @@ files:
73
73
  - bin/gitflash
74
74
  - command_descriptions.yml
75
75
  - lib/gitflash.rb
76
+ - lib/gitflash/atomic_file.rb
76
77
  - lib/gitflash/branch.rb
77
78
  - lib/gitflash/cherry_pick.rb
79
+ - lib/gitflash/cherry_pick/log_parser.rb
78
80
  - lib/gitflash/cleanup.rb
79
81
  - lib/gitflash/cli.rb
80
82
  - lib/gitflash/command_runner.rb
@@ -117,9 +119,19 @@ files:
117
119
  - lib/gitflash/hook/branch_marker.rb
118
120
  - lib/gitflash/hook/claude.rb
119
121
  - lib/gitflash/hook/command_parser.rb
122
+ - lib/gitflash/hook/command_words.rb
120
123
  - lib/gitflash/hook/creations.rb
124
+ - lib/gitflash/hook/failure.rb
125
+ - lib/gitflash/hook/git_invocation.rb
126
+ - lib/gitflash/hook/history_rules.rb
127
+ - lib/gitflash/hook/losses.rb
128
+ - lib/gitflash/hook/messages.rb
129
+ - lib/gitflash/hook/paths.rb
121
130
  - lib/gitflash/hook/rules.rb
122
131
  - lib/gitflash/hook/settings.rb
132
+ - lib/gitflash/hook/shell_quotes.rb
133
+ - lib/gitflash/hook/shell_text.rb
134
+ - lib/gitflash/hook/wrappers.rb
123
135
  - lib/gitflash/hook_cli.rb
124
136
  - lib/gitflash/ownership.rb
125
137
  - lib/gitflash/prompt.rb
@@ -128,11 +140,14 @@ files:
128
140
  - lib/gitflash/restore.rb
129
141
  - lib/gitflash/result.rb
130
142
  - lib/gitflash/snapshot.rb
143
+ - lib/gitflash/snapshot_check.rb
131
144
  - lib/gitflash/snapshots.rb
132
145
  - lib/gitflash/state_capture.rb
133
146
  - lib/gitflash/terminal_text.rb
134
147
  - lib/gitflash/ui.rb
148
+ - lib/gitflash/untracked_files.rb
135
149
  - lib/gitflash/usage_error.rb
150
+ - lib/gitflash/usage_report.rb
136
151
  - lib/gitflash/version.rb
137
152
  - lib/gitflash/worktree.rb
138
153
  - lib/gitflash/worktree_cli.rb