slipway 0.1.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 +7 -0
- data/.yardopts +7 -0
- data/CHANGELOG.md +45 -0
- data/LICENSE.txt +21 -0
- data/README.md +1013 -0
- data/exe/slipway +10 -0
- data/lib/slipway/cli/builtins.rb +241 -0
- data/lib/slipway/cli/completer.rb +158 -0
- data/lib/slipway/cli/completion_scripts.rb +163 -0
- data/lib/slipway/cli/context.rb +67 -0
- data/lib/slipway/cli/errors.rb +19 -0
- data/lib/slipway/cli/globals.rb +27 -0
- data/lib/slipway/cli/help_renderer.rb +135 -0
- data/lib/slipway/cli/manpage.rb +226 -0
- data/lib/slipway/cli/parser.rb +45 -0
- data/lib/slipway/cli/registry.rb +191 -0
- data/lib/slipway/cli/runner.rb +186 -0
- data/lib/slipway/cli/style.rb +82 -0
- data/lib/slipway/cli/theme.rb +85 -0
- data/lib/slipway/cli/validator.rb +61 -0
- data/lib/slipway/cli.rb +22 -0
- data/lib/slipway/command_line.rb +22 -0
- data/lib/slipway/commands/api_resources.rb +82 -0
- data/lib/slipway/commands/apply.rb +172 -0
- data/lib/slipway/commands/base.rb +50 -0
- data/lib/slipway/commands/config.rb +73 -0
- data/lib/slipway/commands/create.rb +218 -0
- data/lib/slipway/commands/delete.rb +82 -0
- data/lib/slipway/commands/describe.rb +74 -0
- data/lib/slipway/commands/diff.rb +122 -0
- data/lib/slipway/commands/edit.rb +130 -0
- data/lib/slipway/commands/explain.rb +97 -0
- data/lib/slipway/commands/fetch.rb +112 -0
- data/lib/slipway/commands/from_dir.rb +141 -0
- data/lib/slipway/commands/get.rb +167 -0
- data/lib/slipway/commands/label.rb +114 -0
- data/lib/slipway/commands/manual.rb +67 -0
- data/lib/slipway/commands/options.rb +73 -0
- data/lib/slipway/commands/results.rb +57 -0
- data/lib/slipway/commands/rollout.rb +114 -0
- data/lib/slipway/commands/rollout_spec.rb +99 -0
- data/lib/slipway/commands/rollout_undo.rb +126 -0
- data/lib/slipway/commands/scope.rb +156 -0
- data/lib/slipway/commands/sync.rb +140 -0
- data/lib/slipway/commands.rb +54 -0
- data/lib/slipway/drift.rb +87 -0
- data/lib/slipway/editor.rb +71 -0
- data/lib/slipway/error.rb +27 -0
- data/lib/slipway/fetcher.rb +99 -0
- data/lib/slipway/field_selector.rb +86 -0
- data/lib/slipway/git/branch_name.rb +32 -0
- data/lib/slipway/git/commit.rb +13 -0
- data/lib/slipway/git/distance.rb +13 -0
- data/lib/slipway/git/errors.rb +125 -0
- data/lib/slipway/git/fake.rb +147 -0
- data/lib/slipway/git/fast_forward.rb +12 -0
- data/lib/slipway/git/fast_forwarding.rb +148 -0
- data/lib/slipway/git/fetch_result.rb +22 -0
- data/lib/slipway/git/move_back.rb +12 -0
- data/lib/slipway/git/reflog.rb +25 -0
- data/lib/slipway/git/repository.rb +288 -0
- data/lib/slipway/git/rolling_back.rb +98 -0
- data/lib/slipway/git/runner.rb +175 -0
- data/lib/slipway/git/status.rb +110 -0
- data/lib/slipway/git/url.rb +95 -0
- data/lib/slipway/git.rb +20 -0
- data/lib/slipway/inspector.rb +103 -0
- data/lib/slipway/labels.rb +126 -0
- data/lib/slipway/manifest.rb +265 -0
- data/lib/slipway/names.rb +22 -0
- data/lib/slipway/outcome.rb +45 -0
- data/lib/slipway/output/age.rb +70 -0
- data/lib/slipway/output/describe.rb +71 -0
- data/lib/slipway/output/explain.rb +75 -0
- data/lib/slipway/output/serializer.rb +35 -0
- data/lib/slipway/output/table.rb +67 -0
- data/lib/slipway/output.rb +28 -0
- data/lib/slipway/paths.rb +65 -0
- data/lib/slipway/plan.rb +227 -0
- data/lib/slipway/pool.rb +94 -0
- data/lib/slipway/resources.rb +91 -0
- data/lib/slipway/rollback.rb +236 -0
- data/lib/slipway/rollout_history.rb +69 -0
- data/lib/slipway/runtime.rb +65 -0
- data/lib/slipway/scanner.rb +54 -0
- data/lib/slipway/schema.rb +128 -0
- data/lib/slipway/selector.rb +146 -0
- data/lib/slipway/settings.rb +174 -0
- data/lib/slipway/state.rb +82 -0
- data/lib/slipway/store.rb +170 -0
- data/lib/slipway/syncer.rb +139 -0
- data/lib/slipway/version.rb +5 -0
- data/lib/slipway/views/group.rb +35 -0
- data/lib/slipway/views/project.rb +148 -0
- data/lib/slipway/views.rb +10 -0
- data/lib/slipway/yaml.rb +14 -0
- data/lib/slipway.rb +32 -0
- data/man/man1/slipway-api-resources.1 +53 -0
- data/man/man1/slipway-apply.1 +45 -0
- data/man/man1/slipway-completion.1 +29 -0
- data/man/man1/slipway-config-path.1 +20 -0
- data/man/man1/slipway-config-view.1 +25 -0
- data/man/man1/slipway-config.1 +22 -0
- data/man/man1/slipway-create.1 +89 -0
- data/man/man1/slipway-delete.1 +46 -0
- data/man/man1/slipway-describe.1 +95 -0
- data/man/man1/slipway-diff.1 +120 -0
- data/man/man1/slipway-edit.1 +34 -0
- data/man/man1/slipway-explain.1 +36 -0
- data/man/man1/slipway-fetch.1 +77 -0
- data/man/man1/slipway-get.1 +155 -0
- data/man/man1/slipway-help.1 +19 -0
- data/man/man1/slipway-label.1 +53 -0
- data/man/man1/slipway-man.1 +36 -0
- data/man/man1/slipway-rollout-history.1 +28 -0
- data/man/man1/slipway-rollout-pause.1 +21 -0
- data/man/man1/slipway-rollout-resume.1 +21 -0
- data/man/man1/slipway-rollout-undo.1 +73 -0
- data/man/man1/slipway-rollout-unpin.1 +21 -0
- data/man/man1/slipway-rollout.1 +36 -0
- data/man/man1/slipway-sync.1 +90 -0
- data/man/man1/slipway-version.1 +17 -0
- data/man/man1/slipway.1 +243 -0
- metadata +173 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'errors'
|
|
4
|
+
|
|
5
|
+
module Slipway
|
|
6
|
+
module Git
|
|
7
|
+
class Repository
|
|
8
|
+
# A write to a working tree, and the checks and refusals around it. It runs through
|
|
9
|
+
# Repository's own runner, status and in-progress lookup.
|
|
10
|
+
module FastForwarding
|
|
11
|
+
UPSTREAM = '@{upstream}'
|
|
12
|
+
REFLOG_ACTION = 'slipway sync'
|
|
13
|
+
REFLOG_VARIABLE = 'GIT_REFLOG_ACTION'
|
|
14
|
+
# Abbreviations are refused: one that is unique today can name two commits tomorrow.
|
|
15
|
+
OBJECT_NAME = /\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/
|
|
16
|
+
# --no-autostash overrides merge.autoStash, under which git would stash local changes and
|
|
17
|
+
# leave a conflicted tree behind when they no longer apply. --no-overwrite-ignore: by
|
|
18
|
+
# default git replaces or removes an ignored file that stands in the way of an incoming
|
|
19
|
+
# path, and that content was never committed.
|
|
20
|
+
MERGE_ARGS = %w[merge --ff-only --no-stat --quiet --no-autostash --no-overwrite-ignore].freeze
|
|
21
|
+
UNKNOWN_REVISION = 'unknown revision'
|
|
22
|
+
# Git's refusals under LC_ALL=C. Each leaves the branch, the index and the working tree as
|
|
23
|
+
# they were.
|
|
24
|
+
REFUSALS = {
|
|
25
|
+
/^(?:error|fatal): Unable to create '.*index\.lock': File exists/ => Busy,
|
|
26
|
+
/^error: The following untracked working tree files would be (?:overwritten|removed)/ => WouldOverwrite,
|
|
27
|
+
/^error: Untracked working tree file '.*' would be (?:overwritten|removed)/ => WouldOverwrite,
|
|
28
|
+
/^error: Updating the following directories would lose untracked files/ => WouldOverwrite,
|
|
29
|
+
/^error: Updating '.*' would lose untracked files in it/ => WouldOverwrite,
|
|
30
|
+
/^error: Your local changes to the following files would be overwritten/ => WouldLoseChanges,
|
|
31
|
+
/^error: Entry '.*' not uptodate\. Cannot merge/ => WouldLoseChanges,
|
|
32
|
+
/^fatal: Not possible to fast-forward/ => NotFastForward
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
# `onto` is @{upstream} or a full object name its upstream holds. Raises Blocked, or a
|
|
36
|
+
# subclass for git's own refusal, and the branch then stays where it was. Raises
|
|
37
|
+
# WriteTimeout when the deadline stops git partway through the checkout: the branch stays
|
|
38
|
+
# too, but the files git had written stay in the working tree.
|
|
39
|
+
def fast_forward(path, onto: UPSTREAM, reflog_action: REFLOG_ACTION)
|
|
40
|
+
unless onto == UPSTREAM || OBJECT_NAME.match?(onto)
|
|
41
|
+
raise ArgumentError, "onto must be #{UPSTREAM} or a full object name, not #{onto.inspect}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
directory = File.expand_path(path)
|
|
45
|
+
status = ready_status(directory)
|
|
46
|
+
from, to = revisions(directory, onto)
|
|
47
|
+
ahead, behind = position(directory, from, to)
|
|
48
|
+
return FastForward.new(from:, to: from, count: 0) if behind.zero?
|
|
49
|
+
|
|
50
|
+
target = onto == UPSTREAM ? status.upstream : to[0, Porcelain::ABBREVIATION]
|
|
51
|
+
raise Blocked.new(directory, 'Diverged', "#{ahead} ahead, #{behind} behind #{target}") if ahead.positive?
|
|
52
|
+
|
|
53
|
+
along_upstream(directory, to, status.upstream) unless onto == UPSTREAM
|
|
54
|
+
merge(directory, to, reflog_action)
|
|
55
|
+
FastForward.new(from:, to:, count: behind)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# `clean` refuses staged and unstaged changes; without it git alone decides whether the move
|
|
61
|
+
# would overwrite them.
|
|
62
|
+
def ready_status(directory, clean: true)
|
|
63
|
+
status = status(directory)
|
|
64
|
+
blocker = blocker(status, clean:)
|
|
65
|
+
raise Blocked.new(directory, *blocker) if blocker
|
|
66
|
+
|
|
67
|
+
locks = branch_locks(status.branch)
|
|
68
|
+
paths = git_paths(directory, *IN_PROGRESS.keys, SEQUENCER_TODO, *locks)
|
|
69
|
+
operation = operation(directory, paths.shift(IN_PROGRESS.size + 1))
|
|
70
|
+
raise Blocked.new(directory, 'InProgress', "#{operation} in progress") if operation
|
|
71
|
+
|
|
72
|
+
held = locks.zip(paths).find { |_, lock| File.exist?(lock) }&.first
|
|
73
|
+
raise Busy.new(directory, held) if held
|
|
74
|
+
|
|
75
|
+
status
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Git takes these when it updates the branch, after it has checked out the new tree and
|
|
79
|
+
# written the index, so one left behind would leave the tree moved and the branch not.
|
|
80
|
+
def branch_locks(branch) = ['HEAD.lock', "refs/heads/#{branch}.lock", 'reftable/tables.list.lock']
|
|
81
|
+
|
|
82
|
+
# In State.derive's order, so a blocking state is named as the STATUS column names it.
|
|
83
|
+
# Untracked files do not block: git refuses to overwrite one.
|
|
84
|
+
def blocker(status, clean:)
|
|
85
|
+
return ['Conflicted', unmerged(status.conflicted)] if status.conflicted.positive?
|
|
86
|
+
return ['Detached', "HEAD is detached at #{status.head}"] if status.detached?
|
|
87
|
+
return ['Unborn', 'no commits yet'] if status.unborn?
|
|
88
|
+
return ['Dirty', changes(status)] if clean && !(status.staged + status.unstaged).zero?
|
|
89
|
+
|
|
90
|
+
tracking(status)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def tracking(status)
|
|
94
|
+
return ['NoUpstream', "#{status.branch} tracks no upstream"] if status.upstream.nil?
|
|
95
|
+
|
|
96
|
+
['Gone', "upstream #{status.upstream} no longer exists"] if status.upstream_gone?
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def unmerged(count) = count == 1 ? '1 unmerged path' : "#{count} unmerged paths"
|
|
100
|
+
|
|
101
|
+
def changes(status) = "#{status.staged} staged, #{status.unstaged} unstaged"
|
|
102
|
+
|
|
103
|
+
# The merge names the commit the checks saw, not @{upstream}: a fetch running beside it
|
|
104
|
+
# could move the upstream in between, and the branch would then move further than checked.
|
|
105
|
+
def revisions(directory, onto)
|
|
106
|
+
missing = ->(failed) { onto != UPSTREAM && failed.err.include?(UNKNOWN_REVISION) }
|
|
107
|
+
result = run(directory, 'rev-parse', 'HEAD', "#{onto}^{commit}", accept: missing)
|
|
108
|
+
return result.out.split if result.success?
|
|
109
|
+
|
|
110
|
+
raise Blocked.new(directory, 'RevisionNotFound', "commit #{onto} is not in this repository")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def position(directory, from, to)
|
|
114
|
+
run(directory, 'rev-list', '--left-right', '--count', "#{from}...#{to}").out.split.map { Integer(it) }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# A commit the upstream lacks could come from any branch or fork the repository has
|
|
118
|
+
# fetched, so a named commit is reached only along the upstream.
|
|
119
|
+
def along_upstream(directory, commit, upstream)
|
|
120
|
+
return if upstream_holds?(directory, commit)
|
|
121
|
+
|
|
122
|
+
short = commit[0, Porcelain::ABBREVIATION]
|
|
123
|
+
raise Blocked.new(directory, 'OffUpstream', "commit #{short} is not on #{upstream}")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# merge-base --is-ancestor exits 1 without a word when the commit is not an ancestor.
|
|
127
|
+
def upstream_holds?(directory, commit)
|
|
128
|
+
lacks = ->(failed) { failed.status == 1 }
|
|
129
|
+
run(directory, 'merge-base', '--is-ancestor', commit, UPSTREAM, accept: lacks).success?
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# A partial clone fetches the blobs a checkout needs, so the merge runs as a network command:
|
|
133
|
+
# no prompt, the allowed protocols only, and the network deadline. The deadline cannot tell
|
|
134
|
+
# that fetch from the write, and a smudge filter such as git-lfs's may contact a server too.
|
|
135
|
+
def merge(directory, commit, reflog_action)
|
|
136
|
+
result = run(directory, *Runner::NETWORK_CONFIG, *MERGE_ARGS, commit,
|
|
137
|
+
accept: method(:refusal), network: true, timeout: @network_timeout,
|
|
138
|
+
env: @network_environment.merge(REFLOG_VARIABLE => reflog_action))
|
|
139
|
+
raise refusal(result), directory unless result.success?
|
|
140
|
+
rescue Timeout
|
|
141
|
+
raise WriteTimeout.new(directory, seconds: @network_timeout)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def refusal(result) = REFUSALS.find { |pattern, _| pattern.match?(result.err) }&.last
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slipway
|
|
4
|
+
module Git
|
|
5
|
+
# `updates` holds [ref, old, new] for each ref the fetch moved, or is nil when git fetched
|
|
6
|
+
# without listing them (git before 2.41). The old id of a new ref and the new id of a pruned
|
|
7
|
+
# one are git's all-zero id.
|
|
8
|
+
FetchResult = Data.define(:updates) do
|
|
9
|
+
# Parses `git fetch --porcelain`: one "FLAG OLD NEW REF" line per updated ref, and nothing
|
|
10
|
+
# when the fetch brought nothing new. A fetched ref that no refspec maps (a single-branch
|
|
11
|
+
# clone on a branch that tracks another one, a remote without a fetch refspec) is reported
|
|
12
|
+
# as FETCH_HEAD on every fetch, though no ref moved.
|
|
13
|
+
def self.parse(text)
|
|
14
|
+
updates = text.each_line(chomp: true).filter_map do |line|
|
|
15
|
+
old_id, new_id, ref = line[2..].split(' ', 3)
|
|
16
|
+
[ref, old_id, new_id].freeze unless ref == Repository::FETCH_HEAD
|
|
17
|
+
end
|
|
18
|
+
new(updates: updates.freeze)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slipway
|
|
4
|
+
module Git
|
|
5
|
+
# `from` and `to` are the full object names the branch pointed at before and after the move,
|
|
6
|
+
# and `count` the commits it dropped: 0, with `to` equal to `from`, when the branch already
|
|
7
|
+
# stood at the target.
|
|
8
|
+
MoveBack = Data.define(:from, :to, :count) do
|
|
9
|
+
def moved? = to != from
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slipway
|
|
4
|
+
module Git
|
|
5
|
+
# One move of a branch as its reflog records it: the commit the branch pointed at after the
|
|
6
|
+
# move, when the move happened, and git's subject for it, such as "slipway sync: Fast-forward".
|
|
7
|
+
ReflogEntry = Data.define(:sha, :time, :subject)
|
|
8
|
+
|
|
9
|
+
module Reflog
|
|
10
|
+
# A date format makes %gd name the time of each move; %ct would be the commit's own date,
|
|
11
|
+
# the same for every move to one commit. log.showSignature would add gpg's lines.
|
|
12
|
+
FORMAT = ['--date=unix', '--format=%H%x00%gd%x00%gs', '--no-show-signature'].freeze
|
|
13
|
+
SELECTOR_TIME = /@\{(\d+)\}\z/
|
|
14
|
+
FIELDS = 3
|
|
15
|
+
|
|
16
|
+
# Parses `git reflog show -z` in FORMAT, newest entry first as git lists them.
|
|
17
|
+
def self.parse(text)
|
|
18
|
+
text.chomp("\0").split("\0", -1).each_slice(FIELDS).filter_map do |sha, selector, subject|
|
|
19
|
+
time = selector.to_s[SELECTOR_TIME, 1]
|
|
20
|
+
ReflogEntry.new(sha:, time: Time.at(Integer(time, 10)).utc, subject:) if time && subject
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'fast_forwarding'
|
|
4
|
+
require_relative 'reflog'
|
|
5
|
+
require_relative 'rolling_back'
|
|
6
|
+
require_relative 'runner'
|
|
7
|
+
|
|
8
|
+
module Slipway
|
|
9
|
+
module Git
|
|
10
|
+
# Every git command slipway runs, asked of one registered path at a time. Each method runs
|
|
11
|
+
# git through the Runner, under the local deadline or, for #fetch and the moves, the network
|
|
12
|
+
# profile, and raises a Git::Error subclass when git fails: MissingPath, NotARepository,
|
|
13
|
+
# UnsafeRepository, NotInstalled or Timeout for the failures it names, a plain Error with
|
|
14
|
+
# git's first line for the rest. It keeps no state about a repository, so one instance
|
|
15
|
+
# serves every worker of a run; all it remembers is whether git predates
|
|
16
|
+
# `fetch --porcelain`.
|
|
17
|
+
class Repository
|
|
18
|
+
include FastForwarding
|
|
19
|
+
include RollingBack
|
|
20
|
+
|
|
21
|
+
STATUS_ARGS = %w[status --porcelain=v2 --branch --show-stash -z --untracked-files=normal --no-renames].freeze
|
|
22
|
+
# Six NUL separated fields in the order Commit.parse expects; -z terminates the record.
|
|
23
|
+
LOG_ARGS = ['log', '-1', '-z', '--format=%H%x00%h%x00%ct%x00%an%x00%ae%x00%s'].freeze
|
|
24
|
+
REMOTE_ARGS = %w[config --get remote.origin.url].freeze
|
|
25
|
+
# No remote argument: git fetches the remote of the current branch, else the only remote,
|
|
26
|
+
# else origin, as the user's own `git fetch` would, so nothing from a manifest reaches this
|
|
27
|
+
# argv. --no-all overrides fetch.all (git 2.44 and later), under which git would fetch every
|
|
28
|
+
# remote and --atomic would refuse to run.
|
|
29
|
+
FETCH_ARGS = %w[--no-all --atomic --no-recurse-submodules --no-auto-maintenance].freeze
|
|
30
|
+
# A branch that tracks another local branch has "." as its remote, and a remote-less fetch
|
|
31
|
+
# would read the repository itself: no remote-tracking ref moves, yet FETCH_HEAD is rewritten.
|
|
32
|
+
UPSTREAM_REMOTE_ARGS = ['for-each-ref', '--format=%(HEAD)%(upstream:remotename)', 'refs/heads/'].freeze
|
|
33
|
+
CURRENT_LOCAL_UPSTREAM = '*.'
|
|
34
|
+
# ls-remote without a remote picks one the way fetch does, and --get-url prints its URL
|
|
35
|
+
# without contacting it; with none to pick, git dies with this message, untranslated.
|
|
36
|
+
DEFAULT_REMOTE_ARGS = %w[ls-remote --get-url].freeze
|
|
37
|
+
NO_DEFAULT_REMOTE = 'No remote configured'
|
|
38
|
+
# Git learned `fetch --porcelain` in 2.41. An older git rejects the option as a usage error
|
|
39
|
+
# before it connects, and the fetch runs again without it.
|
|
40
|
+
PORCELAIN = '--porcelain'
|
|
41
|
+
PORCELAIN_UNKNOWN = /\Aerror: unknown option .porcelain'$/
|
|
42
|
+
USAGE_STATUS = 129
|
|
43
|
+
FETCH_HEAD = 'FETCH_HEAD'
|
|
44
|
+
COMMON_DIR_ARGS = %w[rev-parse --git-common-dir].freeze
|
|
45
|
+
# `git config --get` exits 1 when the key is absent, which is an answer, not a failure.
|
|
46
|
+
ABSENT_KEY_STATUS = 1
|
|
47
|
+
UNBORN_MESSAGE = 'does not have any commits yet'
|
|
48
|
+
MESSAGE_LIMIT = 200
|
|
49
|
+
NETWORK_TIMEOUT = 60
|
|
50
|
+
PROTOCOLS = %w[ssh https].freeze
|
|
51
|
+
PROTOCOLS_SOURCE = '"protocols" in the configuration file'
|
|
52
|
+
# What git and ssh print when credentials or a host key are missing, would need a prompt, or
|
|
53
|
+
# were refused by the remote.
|
|
54
|
+
AUTH_REQUIRED = Regexp.union('terminal prompts disabled', 'could not read Username', 'could not read Password',
|
|
55
|
+
'Authentication failed', 'Permission denied (publickey',
|
|
56
|
+
'Host key verification failed', 'returned error: 403')
|
|
57
|
+
# Git refuses a transport before it connects, so a real refusal is the whole of stderr and
|
|
58
|
+
# ends in die(). Over ssh the remote writes to the same stream, and a line it prints is
|
|
59
|
+
# followed by git's own message or ends in a signal (128 plus its number).
|
|
60
|
+
PROTOCOL_REFUSED = /\Afatal: transport '(?<protocol>[a-z][a-z0-9+.-]*)' not allowed\n\z/
|
|
61
|
+
DIE_STATUS = 128
|
|
62
|
+
# The paths git keeps in the git directory of a worktree while an operation waits for the
|
|
63
|
+
# user, in the order they are checked; the applying marker tells git am from a rebase. A
|
|
64
|
+
# rebase that stops on a merge also leaves MERGE_HEAD, and it is the rebase that must be
|
|
65
|
+
# continued or aborted.
|
|
66
|
+
IN_PROGRESS = { 'rebase-apply/applying' => 'git am session', 'rebase-apply' => 'rebase',
|
|
67
|
+
'rebase-merge' => 'rebase', 'MERGE_HEAD' => 'merge', 'BISECT_LOG' => 'bisect' }.freeze
|
|
68
|
+
# The reftable backend keeps these in its ref store, where no path names them.
|
|
69
|
+
IN_PROGRESS_REFS = { 'CHERRY_PICK_HEAD' => 'cherry-pick', 'REVERT_HEAD' => 'revert' }.freeze
|
|
70
|
+
# A cherry-pick or revert of several commits is still under way after `git reset` drops its
|
|
71
|
+
# pseudoref, and git names it by the first command left in the sequencer's todo.
|
|
72
|
+
SEQUENCER_TODO = 'sequencer/todo'
|
|
73
|
+
SEQUENCER_COMMANDS = { 'pick' => 'cherry-pick', 'revert' => 'revert' }.freeze
|
|
74
|
+
LAZY_FETCH_VARIABLE = 'GIT_NO_LAZY_FETCH'
|
|
75
|
+
# A partial clone fetches an object it lacks from its remote, even for a read. --missing
|
|
76
|
+
# stops that on every supported git; GIT_NO_LAZY_FETCH, which git honors from 2.44, and an
|
|
77
|
+
# empty GIT_ALLOW_PROTOCOL, which refuses every transport before it connects, stand behind it.
|
|
78
|
+
OFFLINE_REV_LIST = %w[rev-list --missing=allow-any].freeze
|
|
79
|
+
REFLOG_ARGS = ['reflog', 'show', '-z', *Reflog::FORMAT].freeze
|
|
80
|
+
BAD_REVISION = 'bad revision'
|
|
81
|
+
OFFLINE_READ = { LAZY_FETCH_VARIABLE => '1', Runner::PROTOCOL_VARIABLE => '' }.freeze
|
|
82
|
+
|
|
83
|
+
def initialize(runner: Runner.new, network_timeout: NETWORK_TIMEOUT, protocols: PROTOCOLS,
|
|
84
|
+
protocols_source: PROTOCOLS_SOURCE)
|
|
85
|
+
@runner = runner
|
|
86
|
+
@network_timeout = network_timeout
|
|
87
|
+
@network_environment = Runner.network_environment(protocols)
|
|
88
|
+
@protocols_source = protocols_source
|
|
89
|
+
# Cleared once git turns out to predate --porcelain; threads racing on it only repeat
|
|
90
|
+
# the rejected attempt.
|
|
91
|
+
@porcelain = true
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def status(path)
|
|
95
|
+
Status.parse(run(path, *STATUS_ARGS).out)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def last_commit(path)
|
|
99
|
+
result = run(path, *LOG_ARGS, accept: method(:unborn?))
|
|
100
|
+
result.success? ? Commit.parse(result.out) : nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def remote_url(path)
|
|
104
|
+
result = run(path, *REMOTE_ARGS, accept: ->(failed) { failed.status == ABSENT_KEY_STATUS })
|
|
105
|
+
result.success? ? result.out.chomp : nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Fetches from the remote git picks. Returns a FetchResult whose `updates` lists the refs
|
|
109
|
+
# that moved, empty when none did, or is nil when git predates 2.41 and cannot tell.
|
|
110
|
+
# Raises LocalUpstream, before anything is contacted, for a branch that tracks a local one,
|
|
111
|
+
# and AuthRequired, ProtocolNotAllowed, Timeout, NotInstalled or MissingPath when the fetch
|
|
112
|
+
# fails for those reasons.
|
|
113
|
+
def fetch(path, prune:)
|
|
114
|
+
raise LocalUpstream, File.expand_path(path) if local_upstream?(path)
|
|
115
|
+
|
|
116
|
+
options = [*FETCH_ARGS, *('--prune' if prune)]
|
|
117
|
+
if @porcelain
|
|
118
|
+
result = network_fetch(path, PORCELAIN, *options, accept: method(:porcelain_unknown?))
|
|
119
|
+
return FetchResult.parse(result.out) if result.success?
|
|
120
|
+
|
|
121
|
+
@porcelain = false
|
|
122
|
+
end
|
|
123
|
+
network_fetch(path, *options)
|
|
124
|
+
FetchResult.new(updates: nil)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def local_upstream?(path)
|
|
128
|
+
run(path, *UPSTREAM_REMOTE_ARGS).out.lines(chomp: true).include?(CURRENT_LOCAL_UPSTREAM)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def default_remote?(path)
|
|
132
|
+
run(path, *DEFAULT_REMOTE_ARGS, accept: method(:no_default_remote?)).success?
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The operation git is in the middle of in this worktree, such as "rebase", or nil.
|
|
136
|
+
def in_progress(path)
|
|
137
|
+
directory = File.expand_path(path)
|
|
138
|
+
operation(directory, git_paths(directory, *IN_PROGRESS.keys, SEQUENCER_TODO))
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# How far HEAD is from `revision`, a full object name, or nil when the repository holds no
|
|
142
|
+
# commit by that name. `tracking` says the branch has an upstream that exists, and a HEAD
|
|
143
|
+
# behind the revision then also learns how many of its commits the upstream lacks.
|
|
144
|
+
def distance(path, revision, tracking: false)
|
|
145
|
+
unless OBJECT_NAME.match?(revision)
|
|
146
|
+
raise ArgumentError, "revision must be a full object name, not #{revision.inspect}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
ahead, behind = offline_count(path, "HEAD...#{revision}^{commit}", '--left-right')
|
|
150
|
+
return if ahead.nil?
|
|
151
|
+
|
|
152
|
+
counted = tracking && ahead.zero? && behind.positive?
|
|
153
|
+
off_upstream = offline_count(path, "#{UPSTREAM}..#{revision}^{commit}").first if counted
|
|
154
|
+
Distance.new(ahead:, behind:, off_upstream:)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The moves git logged for `branch`, newest first. Git answers "bad revision" for a branch
|
|
158
|
+
# without commits, and logs nothing under core.logAllRefUpdates=false unless a log exists.
|
|
159
|
+
def reflog(path, branch)
|
|
160
|
+
result = run(path, *REFLOG_ARGS, "refs/heads/#{branch}", '--',
|
|
161
|
+
accept: ->(failed) { failed.err.include?(BAD_REVISION) })
|
|
162
|
+
result.success? ? Reflog.parse(result.out) : []
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# How many commits `to` reaches that `from` does not, both full object names, or nil when the
|
|
166
|
+
# repository lacks either.
|
|
167
|
+
def commits_between(path, from, to)
|
|
168
|
+
[from, to].each do |name|
|
|
169
|
+
raise ArgumentError, "expected a full object name, not #{name.inspect}" unless OBJECT_NAME.match?(name)
|
|
170
|
+
end
|
|
171
|
+
offline_count(path, "#{from}^{commit}..#{to}^{commit}").first
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# nil for a repository that was never fetched or whose last fetch failed: git empties
|
|
175
|
+
# FETCH_HEAD before it contacts the remote and writes a line for each ref it fetched.
|
|
176
|
+
# Every worktree shares the remote-tracking refs, but git writes FETCH_HEAD in the worktree
|
|
177
|
+
# that ran the fetch, so the newest one dates them.
|
|
178
|
+
def fetched_at(path)
|
|
179
|
+
head = fetch_heads(File.expand_path(path)).filter_map { File.stat(it) if File.file?(it) }.max_by(&:mtime)
|
|
180
|
+
head.mtime.utc if head && !head.zero?
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Symlinks are resolved, so projects on one repository name the same directory however
|
|
184
|
+
# their paths are written. A .git directory is read without a spawn, so listing projects
|
|
185
|
+
# costs no extra git process.
|
|
186
|
+
def common_dir(path)
|
|
187
|
+
directory = File.expand_path(path)
|
|
188
|
+
git_dir = File.join(directory, '.git')
|
|
189
|
+
return File.realpath(git_dir) if File.directory?(git_dir)
|
|
190
|
+
|
|
191
|
+
File.realpath(run(directory, *COMMON_DIR_ARGS).out.chomp, directory)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
private
|
|
195
|
+
|
|
196
|
+
# The spawn is skipped for a path that is not a directory, since git would only say the same.
|
|
197
|
+
def run(path, *, accept: nil, network: false, **)
|
|
198
|
+
directory = File.expand_path(path)
|
|
199
|
+
raise MissingPath, directory unless File.directory?(directory)
|
|
200
|
+
|
|
201
|
+
result = @runner.run(directory, *, **)
|
|
202
|
+
return result if result.success? || accept&.call(result)
|
|
203
|
+
|
|
204
|
+
raise classify(directory, result, network:)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# The counts rev-list prints for `range`, or none when it names an object the repository lacks.
|
|
208
|
+
def offline_count(path, range, *options)
|
|
209
|
+
result = run(path, *OFFLINE_REV_LIST, *options, '--count', range,
|
|
210
|
+
accept: ->(failed) { failed.err.include?(UNKNOWN_REVISION) }, env: OFFLINE_READ)
|
|
211
|
+
result.success? ? result.out.split.map { Integer(it) } : []
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def network_fetch(path, *, accept: nil)
|
|
215
|
+
run(path, *Runner::NETWORK_CONFIG, 'fetch', *, accept:, network: true,
|
|
216
|
+
timeout: @network_timeout, env: @network_environment)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# base: keeps glob metacharacters in the repository path literal.
|
|
220
|
+
def fetch_heads(directory)
|
|
221
|
+
common = common_dir(directory)
|
|
222
|
+
worktrees = File.join(common, 'worktrees')
|
|
223
|
+
[File.join(common, FETCH_HEAD), *Dir.glob("*/#{FETCH_HEAD}", base: worktrees).map { File.join(worktrees, it) }]
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def git_paths(directory, *names)
|
|
227
|
+
paths = run(directory, 'rev-parse', *names.flat_map { ['--git-path', it] }).out.lines(chomp: true)
|
|
228
|
+
paths.map { File.expand_path(it, directory) }
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# `paths` holds the path of each IN_PROGRESS marker, then of the sequencer's todo.
|
|
232
|
+
def operation(directory, paths)
|
|
233
|
+
*markers, todo = paths
|
|
234
|
+
IN_PROGRESS.values.zip(markers).find { |_, marker| File.exist?(marker) }&.first ||
|
|
235
|
+
IN_PROGRESS_REFS.find { |ref, _| ref?(directory, ref) }&.last ||
|
|
236
|
+
sequence(todo)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# rev-parse -q --verify exits 1 without a word for a ref that does not exist.
|
|
240
|
+
def ref?(directory, ref)
|
|
241
|
+
run(directory, 'rev-parse', '-q', '--verify', ref, accept: ->(failed) { failed.status == 1 }).success?
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# The todo holds commit subjects, which need not be UTF-8.
|
|
245
|
+
def sequence(todo)
|
|
246
|
+
SEQUENCER_COMMANDS.fetch(File.binread(todo)[/\S+/], 'cherry-pick')
|
|
247
|
+
rescue Errno::ENOENT, Errno::ENOTDIR
|
|
248
|
+
nil
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def unborn?(result) = result.err.include?(UNBORN_MESSAGE)
|
|
252
|
+
|
|
253
|
+
def porcelain_unknown?(result) = result.status == USAGE_STATUS && PORCELAIN_UNKNOWN.match?(result.err)
|
|
254
|
+
|
|
255
|
+
def no_default_remote?(result) = result.status == DIE_STATUS && result.err.include?(NO_DEFAULT_REMOTE)
|
|
256
|
+
|
|
257
|
+
def classify(path, result, network:)
|
|
258
|
+
failure = network ? network_failure(path, result) : local_failure(path, result)
|
|
259
|
+
failure || Error.new(path, "git exited with status #{result.status}: #{first_line(result.err)}")
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Git's stderr under LC_ALL=C opens with a stable phrase for each local failure Slipway
|
|
263
|
+
# names. A network command's stderr may open with what ssh or the remote printed, so these
|
|
264
|
+
# phrases describe the local repository only for a command that never leaves the machine.
|
|
265
|
+
def local_failure(path, result)
|
|
266
|
+
case result.err
|
|
267
|
+
when /\Afatal: not a git repository/ then NotARepository.new(path)
|
|
268
|
+
when /\Afatal: cannot change to/ then MissingPath.new(path)
|
|
269
|
+
when /\Afatal: detected dubious ownership/ then UnsafeRepository.new(path)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def network_failure(path, result)
|
|
274
|
+
refusal = PROTOCOL_REFUSED.match(result.err) if result.status == DIE_STATUS
|
|
275
|
+
return ProtocolNotAllowed.new(path, protocol: refusal[:protocol], source: @protocols_source) if refusal
|
|
276
|
+
|
|
277
|
+
refused = result.err.each_line.find { AUTH_REQUIRED.match?(it) }
|
|
278
|
+
AuthRequired.new(path, redacted(refused)) if refused
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def first_line(err) = redacted(err.lines.first)
|
|
282
|
+
|
|
283
|
+
# Git quotes the URL it failed on, credentials included, and a server can add lines of its
|
|
284
|
+
# own. Redacting before the cut keeps a password from surviving as a truncated URL.
|
|
285
|
+
def redacted(line) = Url.redact(line.to_s.strip)[0, MESSAGE_LIMIT]
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'errors'
|
|
4
|
+
require_relative 'move_back'
|
|
5
|
+
|
|
6
|
+
module Slipway
|
|
7
|
+
module Git
|
|
8
|
+
class Repository
|
|
9
|
+
# The one reset slipway runs, and the checks around it. It shares the fast-forward's checks
|
|
10
|
+
# of the repository's state, but lets unstaged changes through: reset --keep keeps an
|
|
11
|
+
# unstaged change to a file the move leaves alone and refuses one to a file it rewrites.
|
|
12
|
+
# It resets every index entry, though, so a staged change would be lost and is refused.
|
|
13
|
+
module RollingBack
|
|
14
|
+
ROLLBACK_ACTION = 'slipway rollout undo'
|
|
15
|
+
RESET_ARGS = %w[reset --keep --quiet --no-recurse-submodules].freeze
|
|
16
|
+
# Literal, so a name git would read as pathspec magic stays a name; icase, because on a
|
|
17
|
+
# case-insensitive file system the file in the way may differ in case.
|
|
18
|
+
LITERAL = ':(literal,icase)'
|
|
19
|
+
|
|
20
|
+
# Moves the checked-out branch back to `to`, a full object name of a commit HEAD contains,
|
|
21
|
+
# only when @{upstream} holds every commit the move drops. Raises Blocked, or a subclass for
|
|
22
|
+
# git's own refusal, and the branch then stays where it was, or WriteTimeout as the
|
|
23
|
+
# fast-forward does.
|
|
24
|
+
def roll_back(path, to:, reflog_action: ROLLBACK_ACTION)
|
|
25
|
+
unless FastForwarding::OBJECT_NAME.match?(to)
|
|
26
|
+
raise ArgumentError, "to must be a full object name, not #{to.inspect}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
directory = File.expand_path(path)
|
|
30
|
+
status = ready_status(directory, clean: false)
|
|
31
|
+
raise Blocked.new(directory, 'Dirty', changes(status)) if status.staged.positive?
|
|
32
|
+
|
|
33
|
+
from, to = revisions(directory, to)
|
|
34
|
+
dropped, gained = position(directory, from, to)
|
|
35
|
+
return MoveBack.new(from:, to: from, count: 0) if (dropped + gained).zero?
|
|
36
|
+
|
|
37
|
+
behind_head(directory, status, to, gained)
|
|
38
|
+
kept_upstream(directory, status, from)
|
|
39
|
+
clear_way(directory, from, to)
|
|
40
|
+
reset(directory, to, reflog_action)
|
|
41
|
+
MoveBack.new(from:, to:, count: dropped)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def behind_head(directory, status, commit, gained)
|
|
47
|
+
return if gained.zero?
|
|
48
|
+
|
|
49
|
+
short = commit[0, Porcelain::ABBREVIATION]
|
|
50
|
+
raise Blocked.new(directory, 'Diverged', "commit #{short} is not on the history of #{status.branch}")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Every commit the move drops stays reachable from the upstream, so none of them exists only
|
|
54
|
+
# in this repository.
|
|
55
|
+
def kept_upstream(directory, status, head)
|
|
56
|
+
return if upstream_holds?(directory, head)
|
|
57
|
+
|
|
58
|
+
raise Blocked.new(directory, 'LocalCommits',
|
|
59
|
+
"#{status.branch} has commits that are not on #{status.upstream}")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# reset --keep refuses to overwrite an untracked file but replaces an ignored one, whose
|
|
63
|
+
# content was never committed, so the paths the move adds are checked for either first.
|
|
64
|
+
def clear_way(directory, from, to)
|
|
65
|
+
added = run(directory, 'diff', '--name-only', '-z', '--no-renames', '--diff-filter=A', from, to, '--')
|
|
66
|
+
suspects = added.out.split("\0").flat_map { in_the_way(directory, it) }.uniq
|
|
67
|
+
return if suspects.empty?
|
|
68
|
+
|
|
69
|
+
others = run(directory, 'ls-files', '-z', '--others', '--', *suspects.map { LITERAL + it })
|
|
70
|
+
raise WouldOverwrite, directory unless others.out.empty?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The entries on disk git would have to replace to write `path`: the path itself, and any
|
|
74
|
+
# parent directory that is a file or a symbolic link instead. ls-files then tells whether
|
|
75
|
+
# git tracks them.
|
|
76
|
+
def in_the_way(directory, path)
|
|
77
|
+
parts = path.split('/')
|
|
78
|
+
parents = (1...parts.size).map { parts.first(it).join('/') }
|
|
79
|
+
[*parents.reject { real_directory?(File.join(directory, it)) }, path].select do |entry|
|
|
80
|
+
File.symlink?(File.join(directory, entry)) || File.exist?(File.join(directory, entry))
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def real_directory?(path) = File.directory?(path) && !File.symlink?(path)
|
|
85
|
+
|
|
86
|
+
# A partial clone fetches the blobs the reset writes, so it runs as the merge does.
|
|
87
|
+
def reset(directory, commit, reflog_action)
|
|
88
|
+
result = run(directory, *Runner::NETWORK_CONFIG, *RESET_ARGS, commit, '--',
|
|
89
|
+
accept: method(:refusal), network: true, timeout: @network_timeout,
|
|
90
|
+
env: @network_environment.merge(FastForwarding::REFLOG_VARIABLE => reflog_action))
|
|
91
|
+
raise refusal(result), directory unless result.success?
|
|
92
|
+
rescue Timeout
|
|
93
|
+
raise WriteTimeout.new(directory, seconds: @network_timeout)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|