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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +44 -1
- data/bin/gitflash +10 -0
- data/command_descriptions.yml +23 -0
- data/lib/gitflash/branch.rb +17 -1
- 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/commit.rb +1 -1
- 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 +44 -14
- 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/terminal_text.rb +19 -0
- data/lib/gitflash/ui.rb +9 -7
- data/lib/gitflash/version.rb +1 -1
- data/schema/v1.json +804 -69
- metadata +19 -2
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
108
|
+
HEADS)
|
|
79
109
|
.each_line(chomp: true).reject(&:empty?)
|
|
80
110
|
end
|
|
81
111
|
|
|
82
|
-
#
|
|
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',
|
|
91
|
-
|
|
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 { |
|
|
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
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
$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
|
data/lib/gitflash/version.rb
CHANGED