gitflash 0.5.1 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +44 -1
- data/bin/gitflash +10 -0
- data/command_descriptions.yml +23 -0
- data/lib/gitflash/branch.rb +7 -0
- data/lib/gitflash/cli.rb +35 -21
- data/lib/gitflash/command_runner.rb +23 -0
- data/lib/gitflash/commands/base.rb +9 -0
- data/lib/gitflash/commands/checkout.rb +2 -1
- data/lib/gitflash/commands/delete.rb +6 -3
- data/lib/gitflash/commands/gc.rb +41 -0
- data/lib/gitflash/commands/hook_install.rb +69 -0
- data/lib/gitflash/commands/hook_status.rb +62 -0
- data/lib/gitflash/commands/reset.rb +7 -1
- data/lib/gitflash/commands/snapshot_create.rb +30 -0
- data/lib/gitflash/commands/snapshot_list.rb +20 -0
- data/lib/gitflash/commands/undo.rb +99 -0
- data/lib/gitflash/git/bash_command.rb +5 -4
- data/lib/gitflash/hook/claude.rb +106 -0
- data/lib/gitflash/hook/command_parser.rb +101 -0
- data/lib/gitflash/hook/rules.rb +108 -0
- data/lib/gitflash/hook/settings.rb +60 -0
- data/lib/gitflash/hook_cli.rb +69 -0
- data/lib/gitflash/repo.rb +10 -0
- data/lib/gitflash/restore.rb +140 -0
- data/lib/gitflash/snapshot.rb +47 -0
- data/lib/gitflash/snapshots.rb +70 -0
- data/lib/gitflash/state_capture.rb +129 -0
- data/lib/gitflash/ui.rb +7 -5
- data/lib/gitflash/version.rb +1 -1
- data/schema/v1.json +804 -69
- metadata +17 -1
|
@@ -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
|
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
|
-
|
|
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
|
-
|
|
39
|
-
$stdout.puts JSON.generate(document)
|
|
39
|
+
$stdout.puts JSON.generate(envelope(status: status, **fields))
|
|
40
40
|
else
|
|
41
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
|
|
@@ -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
|
data/lib/gitflash/version.rb
CHANGED