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.
@@ -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
- 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
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gitflash
4
- VERSION = '0.5.1'
4
+ VERSION = '0.6.0'
5
5
  end