gitflash 0.5.0 → 0.6.0

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.
data/lib/gitflash/repo.rb CHANGED
@@ -6,12 +6,14 @@ module Gitflash
6
6
  class Repo
7
7
  SEPARATOR = "\x1f"
8
8
  BRANCH_FORMAT = %w[
9
- refname:short objectname:short HEAD upstream:short upstream:track,nobracket
9
+ refname objectname:short HEAD upstream:short upstream:track,nobracket
10
10
  committerdate:iso-strict authorname subject
11
11
  ].map { |field| "%(#{field})" }.join('%1f')
12
12
  COMMIT_FORMAT = %w[%h %s %an %cI].join('%x1f')
13
13
  COMMITS_LIMIT = 100
14
14
  FALLBACK_DEFAULT_BRANCHES = %w[main master].freeze
15
+ HEADS = 'refs/heads/'
16
+ REMOTE_HEAD = 'refs/remotes/origin/'
15
17
 
16
18
  def initialize(bash: Git::BashCommand)
17
19
  @bash = bash
@@ -37,11 +39,21 @@ module Gitflash
37
39
  branches.find(&:current?)&.name
38
40
  end
39
41
 
42
+ def toplevel
43
+ @bash.exec('git', 'rev-parse', '--show-toplevel').strip
44
+ end
45
+
46
+ # Root of the main checkout; differs from toplevel inside a linked worktree
47
+ def main_root
48
+ common = @bash.exec('git', 'rev-parse', '--path-format=absolute', '--git-common-dir').strip
49
+ File.basename(common) == '.git' ? File.dirname(common) : toplevel
50
+ end
51
+
40
52
  # The branch that origin/HEAD points to, else main or master when present locally
41
53
  def default_branch
42
54
  return @default_branch if defined?(@default_branch)
43
55
 
44
- @default_branch = default_ref&.delete_prefix('origin/')
56
+ @default_branch = branch_name(default_ref)
45
57
  end
46
58
 
47
59
  # Commits of the current branch, newest first. Empty for a branch without commits.
@@ -54,17 +66,22 @@ module Gitflash
54
66
 
55
67
  # Full sha of a commit reference, or nil when it does not name a commit
56
68
  def resolve_commit(ref)
69
+ return nil if ref.start_with?('-')
70
+
57
71
  stdout, _stderr, success = @bash.capture('git', 'rev-parse', '--verify', '--quiet',
58
72
  "#{ref}^{commit}")
59
73
  success ? stdout.strip : nil
60
74
  end
61
75
 
76
+ # Uses `git switch`, which never discards changes or treats the name as a file path.
77
+ # A name that starts with a dash is refused: a ref named like an option must not become one.
62
78
  def checkout(branch)
63
- run('git', 'checkout', branch, '--')
79
+ refuse_option_like!(branch)
80
+ run('git', 'switch', '--', branch)
64
81
  end
65
82
 
66
83
  def delete_branch(branch, force: false)
67
- run('git', 'branch', force ? '-D' : '-d', branch)
84
+ run('git', 'branch', force ? '-D' : '-d', '--', branch)
68
85
  end
69
86
 
70
87
  def reset(commit, mode:)
@@ -73,13 +90,27 @@ module Gitflash
73
90
 
74
91
  private
75
92
 
93
+ # Removes only the prefix the ref starts with, so a branch named `refs/heads/x` keeps its name
94
+ def branch_name(ref)
95
+ return nil unless ref
96
+
97
+ ref.delete_prefix(ref.start_with?(REMOTE_HEAD) ? REMOTE_HEAD : HEADS)
98
+ end
99
+
100
+ def refuse_option_like!(name)
101
+ return unless name.start_with?('-')
102
+
103
+ raise UsageError.new("Invalid branch name '#{name}'", code: 'invalid_usage')
104
+ end
105
+
76
106
  def branch_lines
77
107
  @branch_lines ||= @bash.exec('git', 'for-each-ref', "--format=#{BRANCH_FORMAT}",
78
- 'refs/heads/')
108
+ HEADS)
79
109
  .each_line(chomp: true).reject(&:empty?)
80
110
  end
81
111
 
82
- # Ref used to check merge status: origin/HEAD's target, else a local main/master
112
+ # Full ref used to check merge status: origin/HEAD's target, else a local main/master.
113
+ # Full names are used throughout because short names change when a tag has the same name.
83
114
  def default_ref
84
115
  return @default_ref if defined?(@default_ref)
85
116
 
@@ -87,24 +118,23 @@ module Gitflash
87
118
  end
88
119
 
89
120
  def remote_default_ref
90
- ref = @bash.exec('git', 'symbolic-ref', '--quiet', '--short',
91
- 'refs/remotes/origin/HEAD').strip
92
- ref.empty? ? nil : ref
121
+ ref = @bash.exec('git', 'symbolic-ref', '--quiet', "#{REMOTE_HEAD}HEAD").strip
122
+ ref.start_with?(REMOTE_HEAD) ? ref : nil
93
123
  rescue Git::CommandError
94
124
  nil
95
125
  end
96
126
 
97
127
  def local_default_branch
98
- names = branch_lines.map { |line| line.split(SEPARATOR, 2).first }
99
- FALLBACK_DEFAULT_BRANCHES.find { |name| names.include?(name) }
128
+ names = branch_lines.map { |line| line.split(SEPARATOR, 2).first.delete_prefix(HEADS) }
129
+ name = FALLBACK_DEFAULT_BRANCHES.find { |candidate| names.include?(candidate) }
130
+ name && "#{HEADS}#{name}"
100
131
  end
101
132
 
102
133
  def merged_branch_names
103
134
  return nil unless default_ref
104
135
 
105
- @bash.exec('git', 'for-each-ref', "--merged=#{default_ref}", '--format=%(refname:short)',
106
- 'refs/heads/')
107
- .each_line(chomp: true).to_set
136
+ @bash.exec('git', 'for-each-ref', "--merged=#{default_ref}", '--format=%(refname)', HEADS)
137
+ .each_line(chomp: true).to_set { |ref| ref.delete_prefix(HEADS) }
108
138
  end
109
139
 
110
140
  def run(*)
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ # Plans and applies the restore of a snapshot. Only the parts in the snapshot's scope are
5
+ # restored, and only where the repository differs from the snapshot. Branches and files
6
+ # created after the snapshot are left alone.
7
+ class Restore
8
+ def initialize(snapshot, bash: Git::BashCommand)
9
+ @snapshot = snapshot
10
+ @bash = bash
11
+ end
12
+
13
+ # { branches: [{branch, from, to}], head: {from, to} | nil, worktree: bool, stashes: [sha] }
14
+ def plan
15
+ @plan ||= {
16
+ branches: @snapshot.scope?('branches') ? branch_changes : [],
17
+ head: @snapshot.scope?('head') ? head_change : nil,
18
+ worktree: @snapshot.scope?('worktree') && worktree_differs?,
19
+ stashes: @snapshot.scope?('stashes') ? missing_stashes : []
20
+ }
21
+ end
22
+
23
+ def empty?
24
+ plan[:branches].empty? && plan[:head].nil? && !plan[:worktree] && plan[:stashes].empty?
25
+ end
26
+
27
+ # Applies the plan; returns nil on success or the failed step's Result
28
+ def apply
29
+ steps.each do |args|
30
+ result = run(*args)
31
+ return result unless result.success?
32
+ end
33
+ nil
34
+ end
35
+
36
+ private
37
+
38
+ def steps
39
+ branch_steps + head_steps + worktree_steps + stash_steps
40
+ end
41
+
42
+ def branch_steps
43
+ plan[:branches].map do |change|
44
+ # An empty old value makes git refuse to overwrite a branch created since the plan
45
+ ['update-ref', "refs/heads/#{change[:branch]}", change[:to], change[:from] || '']
46
+ end
47
+ end
48
+
49
+ # With the worktree in scope HEAD is moved directly and the files are restored next;
50
+ # otherwise a normal checkout keeps local changes.
51
+ def head_steps
52
+ return [] unless plan[:head]
53
+
54
+ branch = @snapshot.head[:branch]
55
+ sha = @snapshot.head[:sha]
56
+ if @snapshot.scope?('worktree')
57
+ detach = ['update-ref', '--no-deref', 'HEAD', sha]
58
+ [branch ? ['symbolic-ref', 'HEAD', "refs/heads/#{branch}"] : detach]
59
+ else
60
+ [branch ? ['switch', '--', branch] : ['checkout', '--detach', sha]]
61
+ end
62
+ end
63
+
64
+ # Working tree first (tracked and snapshot-untracked files), then the staged state
65
+ def worktree_steps
66
+ return [] unless plan[:worktree] && target_worktree
67
+
68
+ steps = [['read-tree', '--reset', '-u', target_worktree]]
69
+ steps << ['read-tree', '--reset', target_index] if target_index != target_worktree
70
+ steps
71
+ end
72
+
73
+ def stash_steps
74
+ plan[:stashes].reverse.map { |sha| ['stash', 'store', '-m', 'gitflash undo', sha] }
75
+ end
76
+
77
+ def target_worktree
78
+ @snapshot.worktree || @snapshot.head[:sha]
79
+ end
80
+
81
+ def target_index
82
+ return @snapshot.index if @snapshot.index
83
+
84
+ @snapshot.worktree ? @snapshot.head[:sha] : target_worktree
85
+ end
86
+
87
+ def branch_changes
88
+ tips = current_tips
89
+ @snapshot.branches.filter_map do |name, sha|
90
+ { branch: name, from: tips[name], to: sha } unless tips[name] == sha
91
+ end
92
+ end
93
+
94
+ def head_change
95
+ current = current_head
96
+ target = @snapshot.head
97
+ same = if target[:branch]
98
+ current[:branch] == target[:branch]
99
+ else
100
+ current[:branch].nil? && current[:sha] == target[:sha]
101
+ end
102
+ same ? nil : { from: current[:branch] || current[:sha], to: target[:branch] || target[:sha] }
103
+ end
104
+
105
+ def current_head
106
+ { branch: capture('symbolic-ref', '--quiet', 'HEAD')&.delete_prefix(Repo::HEADS),
107
+ sha: capture('rev-parse', '--verify', '--quiet', 'HEAD^{commit}') }
108
+ end
109
+
110
+ def worktree_differs?
111
+ return true unless capture('status', '--porcelain', '--untracked-files=all').to_s.empty?
112
+
113
+ target_worktree && tree_of(target_worktree) != tree_of('HEAD')
114
+ end
115
+
116
+ def missing_stashes
117
+ @snapshot.stashes - capture('stash', 'list', '--format=%H').to_s.split
118
+ end
119
+
120
+ def current_tips
121
+ Branch.parse_tips(capture('for-each-ref', "--format=#{Branch::TIPS_FORMAT}", Repo::HEADS))
122
+ end
123
+
124
+ def tree_of(ref)
125
+ capture('rev-parse', '--verify', '--quiet', "#{ref}^{tree}")
126
+ end
127
+
128
+ # Stripped stdout of a git command, or nil when it fails
129
+ def capture(*)
130
+ stdout, _stderr, success = @bash.capture('git', *)
131
+ success ? stdout.strip : nil
132
+ end
133
+
134
+ def run(*)
135
+ stdout, stderr, success = @bash.capture('git', *)
136
+ output = [stdout, stderr].map(&:strip).reject(&:empty?).join("\n")
137
+ Result.new(success: success, output: output)
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ # Saved repository state that `gitflash undo` can restore.
5
+ #
6
+ # scope parts of the state that undo restores: branches, head, worktree, stashes
7
+ # head { branch:, sha: } of HEAD; branch is nil on a detached HEAD, sha nil before
8
+ # the first commit
9
+ # branches { name => sha } of the recorded local branches
10
+ # stashes stash commits, newest first
11
+ # index commit whose tree is the staged state, nil when the tree was clean
12
+ # worktree commit whose tree is the working tree including untracked files (not
13
+ # ignored ones), nil when the tree was clean
14
+ # skipped_files untracked files left out because they were too large
15
+ Snapshot = Data.define(
16
+ :id, :created_at, :reason, :scope, :worktree_path, :head, :branches, :stashes,
17
+ :index, :worktree, :skipped_files
18
+ ) do
19
+ def self.from_h(hash)
20
+ new(
21
+ id: hash['id'], created_at: hash['created_at'], reason: hash['reason'],
22
+ scope: hash['scope'], worktree_path: hash['worktree_path'],
23
+ head: { branch: hash.dig('head', 'branch'), sha: hash.dig('head', 'sha') },
24
+ branches: hash['branches'] || {}, stashes: hash['stashes'] || [],
25
+ index: hash['index'], worktree: hash['worktree'], skipped_files: hash['skipped_files'] || []
26
+ )
27
+ end
28
+
29
+ def scope?(part)
30
+ scope.include?(part)
31
+ end
32
+
33
+ # Commits the snapshot must keep reachable
34
+ def object_ids
35
+ ([head[:sha], index, worktree] + branches.values + stashes).compact.uniq
36
+ end
37
+
38
+ # Everything except id, time and reason, to detect an unchanged repository
39
+ def state
40
+ to_h.except(:id, :created_at, :reason)
41
+ end
42
+
43
+ def to_h
44
+ super.merge(branches: branches.transform_keys(&:to_s))
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'securerandom'
5
+ require 'time'
6
+
7
+ module Gitflash
8
+ # Creates, lists and deletes snapshots.
9
+ #
10
+ # Each snapshot is one commit under refs/gitflash/snapshots/<id>. Its message is the snapshot
11
+ # metadata as JSON, its tree is empty, and its parents are every commit the snapshot refers
12
+ # to, so git keeps them all as long as the ref exists. Nothing outside git's own object store
13
+ # is written, and linked worktrees share the snapshots.
14
+ class Snapshots
15
+ REF_PREFIX = 'refs/gitflash/snapshots/'
16
+ SCOPES = %w[branches head worktree stashes].freeze
17
+
18
+ def initialize(bash: Git::BashCommand, clock: -> { Time.now })
19
+ @bash = bash
20
+ @clock = clock
21
+ @capture = StateCapture.new(bash: bash)
22
+ end
23
+
24
+ # Saves the current state. `branches` limits the recorded branches (nil records all).
25
+ # Returns the latest snapshot instead when nothing changed since it was taken.
26
+ def create(reason:, scope: SCOPES, branches: nil)
27
+ state = @capture.call(scope, branches)
28
+ latest = list.first
29
+ return latest if latest&.state == state
30
+
31
+ store(Snapshot.new(**identity, reason: reason, **state))
32
+ end
33
+
34
+ # Snapshots, newest first
35
+ def list
36
+ git('for-each-ref', '--sort=-refname', '--format=%(contents:subject)', REF_PREFIX)
37
+ .each_line(chomp: true).reject(&:empty?)
38
+ .map { |line| Snapshot.from_h(JSON.parse(line)) }
39
+ end
40
+
41
+ def find(id)
42
+ list.find { |snapshot| snapshot.id == id }
43
+ end
44
+
45
+ def delete(id)
46
+ git('update-ref', '-d', "#{REF_PREFIX}#{id}")
47
+ end
48
+
49
+ private
50
+
51
+ def git(*)
52
+ @bash.exec('git', *)
53
+ end
54
+
55
+ # Ids sort by time down to the microsecond, so several snapshots within one second still
56
+ # list in the order they were taken; the random suffix keeps them unique.
57
+ def identity
58
+ now = @clock.call.utc
59
+ id = "#{now.strftime('%Y%m%dT%H%M%S%6N')}-#{SecureRandom.hex(2)}"
60
+ { id: id, created_at: now.iso8601(6) }
61
+ end
62
+
63
+ def store(snapshot)
64
+ meta = @capture.commit_tree(@capture.empty_tree, parents: snapshot.object_ids,
65
+ message: JSON.generate(snapshot.to_h))
66
+ git('update-ref', "#{REF_PREFIX}#{snapshot.id}", meta)
67
+ snapshot
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'tempfile'
5
+
6
+ module Gitflash
7
+ # Reads the repository state a snapshot saves and writes the needed git objects.
8
+ # Never changes branches, the index or the working tree.
9
+ class StateCapture
10
+ MAX_UNTRACKED_FILE_BYTES = 50 * 1024 * 1024
11
+ ADD_BATCH_SIZE = 500
12
+ IDENTITY = {
13
+ 'GIT_AUTHOR_NAME' => 'gitflash', 'GIT_AUTHOR_EMAIL' => 'gitflash@localhost',
14
+ 'GIT_COMMITTER_NAME' => 'gitflash', 'GIT_COMMITTER_EMAIL' => 'gitflash@localhost'
15
+ }.freeze
16
+ # Content commits get a fixed date so the same content always gives the same sha
17
+ FIXED_DATE = {
18
+ 'GIT_AUTHOR_DATE' => '1970-01-01T00:00:00Z', 'GIT_COMMITTER_DATE' => '1970-01-01T00:00:00Z'
19
+ }.freeze
20
+
21
+ def initialize(bash: Git::BashCommand)
22
+ @bash = bash
23
+ end
24
+
25
+ # Snapshot fields except id, time and reason. `branch_names` nil records every branch.
26
+ def call(scope, branch_names)
27
+ head_sha = rev('HEAD')
28
+ state = {
29
+ scope: Snapshots::SCOPES & scope, worktree_path: git('rev-parse', '--show-toplevel'),
30
+ head: { branch: current_branch, sha: head_sha },
31
+ branches: scope.include?('branches') ? branch_tips(branch_names) : {},
32
+ stashes: scope.include?('stashes') ? try('stash', 'list', '--format=%H').to_s.split : [],
33
+ index: nil, worktree: nil, skipped_files: []
34
+ }
35
+ state.merge!(worktree(head_sha)) if scope.include?('worktree') && dirty?
36
+ state
37
+ end
38
+
39
+ def commit_tree(tree, parents:, message:, env: {})
40
+ parent_args = parents.uniq.flat_map { |sha| ['-p', sha] }
41
+ git('commit-tree', tree, *parent_args, '-m', message, env: IDENTITY.merge(env))
42
+ end
43
+
44
+ def empty_tree
45
+ @empty_tree ||= git('mktree')
46
+ end
47
+
48
+ private
49
+
50
+ def git(*, env: {})
51
+ @bash.exec('git', *, env: env).strip
52
+ end
53
+
54
+ # Stripped stdout, or nil when the command fails
55
+ def try(*)
56
+ stdout, _stderr, success = @bash.capture('git', *)
57
+ success ? stdout.strip : nil
58
+ end
59
+
60
+ def rev(ref)
61
+ try('rev-parse', '--verify', '--quiet', "#{ref}^{commit}")
62
+ end
63
+
64
+ def current_branch
65
+ ref = try('symbolic-ref', '--quiet', 'HEAD')
66
+ ref&.delete_prefix(Repo::HEADS)
67
+ end
68
+
69
+ def branch_tips(names)
70
+ tips = Branch.parse_tips(git('for-each-ref', "--format=#{Branch::TIPS_FORMAT}", Repo::HEADS))
71
+ names.nil? ? tips : tips.slice(*names)
72
+ end
73
+
74
+ def dirty?
75
+ !git('status', '--porcelain', '--untracked-files=all').empty?
76
+ end
77
+
78
+ def worktree(head_sha)
79
+ parents = [head_sha].compact
80
+ index_tree = try('write-tree') # nil while a merge conflict is unresolved
81
+ tree, skipped = worktree_tree
82
+ {
83
+ index: index_tree && content_commit(index_tree, parents, 'gitflash index'),
84
+ worktree: content_commit(tree, parents, 'gitflash worktree'),
85
+ skipped_files: skipped
86
+ }
87
+ end
88
+
89
+ def content_commit(tree, parents, message)
90
+ commit_tree(tree, parents: parents, message: message, env: FIXED_DATE)
91
+ end
92
+
93
+ # Tree of the working tree, built in a temporary copy of the index so the real one is untouched
94
+ def worktree_tree
95
+ top = git('rev-parse', '--show-toplevel')
96
+ Tempfile.create('gitflash-index', File.expand_path(git('rev-parse', '--git-dir'))) do |file|
97
+ file.close
98
+ real_index = File.expand_path(git('rev-parse', '--git-path', 'index'))
99
+ FileUtils.cp(real_index, file.path) if File.exist?(real_index)
100
+ env = { 'GIT_INDEX_FILE' => file.path }
101
+
102
+ git('-C', top, 'add', '--update', env: env)
103
+ skipped = add_untracked(top, env)
104
+ [git('write-tree', env: env), skipped]
105
+ end
106
+ end
107
+
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
119
+ def add_untracked(top, env)
120
+ paths = git('-C', top, 'ls-files', '-z', '--others', '--exclude-standard')
121
+ .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
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitflash
4
+ # Makes text from the repository safe to print in a terminal. Commit subjects, author names
5
+ # and git messages are written by other people; control characters in them could move the
6
+ # cursor, rewrite earlier output or set the clipboard (OSC 52). They are shown as escapes.
7
+ # Newlines and tabs stay.
8
+ module TerminalText
9
+ UNSAFE = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/
10
+
11
+ module_function
12
+
13
+ def safe(text)
14
+ text.to_s.scrub.gsub(UNSAFE) do |char|
15
+ format(char.ord > 0xFF ? '\\u%04X' : '\\x%02X', char.ord)
16
+ end
17
+ end
18
+ end
19
+ end
data/lib/gitflash/ui.rb CHANGED
@@ -7,7 +7,7 @@ module Gitflash
7
7
  # Menus and confirmations are only shown in a terminal and never in JSON mode.
8
8
  #
9
9
  # JSON output always uses the same envelope (see schema/v1.json):
10
- # { schema, command, ok, status, dry_run, plan?, result?, error? }
10
+ # { schema, command, ok, status, dry_run, plan?, result?, undo?, error? }
11
11
  class Ui
12
12
  JSON_SCHEMA_VERSION = 1
13
13
  OK_STATUSES = %w[done planned noop cancelled].freeze
@@ -33,12 +33,13 @@ module Gitflash
33
33
 
34
34
  # Reports the outcome of a command and returns its exit status.
35
35
  # `status` is one of done, planned, noop, cancelled or failed.
36
- def report(status:, text:, plan: nil, result: nil, error: nil)
36
+ # `fields` may hold plan, result, error and undo (the snapshot that reverts the change).
37
+ def report(status:, text:, **fields)
37
38
  if json?
38
- document = envelope(status: status, plan: plan, result: result, error: error)
39
- $stdout.puts JSON.generate(document)
39
+ $stdout.puts JSON.generate(envelope(status: status, **fields))
40
40
  else
41
- $stdout.puts text
41
+ $stdout.puts TerminalText.safe(text)
42
+ $stdout.puts "Undo with: gitflash undo #{fields[:undo].id}" if fields[:undo]
42
43
  end
43
44
  OK_STATUSES.include?(status) ? 0 : 1
44
45
  end
@@ -48,7 +49,7 @@ module Gitflash
48
49
  details = { code: error.code, message: error.message, exit_code: error.exit_code }
49
50
  $stdout.puts JSON.generate(envelope(status: error.status, plan: error.plan, error: details))
50
51
  else
51
- warn error.message
52
+ warn TerminalText.safe(error.message)
52
53
  end
53
54
  end
54
55
 
@@ -73,7 +74,7 @@ module Gitflash
73
74
 
74
75
  private
75
76
 
76
- def envelope(status:, plan: nil, result: nil, error: nil)
77
+ def envelope(status:, plan: nil, result: nil, error: nil, undo: nil)
77
78
  {
78
79
  schema: JSON_SCHEMA_VERSION,
79
80
  command: @command,
@@ -82,6 +83,7 @@ module Gitflash
82
83
  dry_run: dry_run?,
83
84
  plan: plan,
84
85
  result: result,
86
+ undo: undo && { snapshot: undo.id, command: "gitflash undo #{undo.id}" },
85
87
  error: error
86
88
  }.compact
87
89
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gitflash
4
- VERSION = '0.5.0'
4
+ VERSION = '0.6.0'
5
5
  end