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.
Files changed (124) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +7 -0
  3. data/CHANGELOG.md +45 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +1013 -0
  6. data/exe/slipway +10 -0
  7. data/lib/slipway/cli/builtins.rb +241 -0
  8. data/lib/slipway/cli/completer.rb +158 -0
  9. data/lib/slipway/cli/completion_scripts.rb +163 -0
  10. data/lib/slipway/cli/context.rb +67 -0
  11. data/lib/slipway/cli/errors.rb +19 -0
  12. data/lib/slipway/cli/globals.rb +27 -0
  13. data/lib/slipway/cli/help_renderer.rb +135 -0
  14. data/lib/slipway/cli/manpage.rb +226 -0
  15. data/lib/slipway/cli/parser.rb +45 -0
  16. data/lib/slipway/cli/registry.rb +191 -0
  17. data/lib/slipway/cli/runner.rb +186 -0
  18. data/lib/slipway/cli/style.rb +82 -0
  19. data/lib/slipway/cli/theme.rb +85 -0
  20. data/lib/slipway/cli/validator.rb +61 -0
  21. data/lib/slipway/cli.rb +22 -0
  22. data/lib/slipway/command_line.rb +22 -0
  23. data/lib/slipway/commands/api_resources.rb +82 -0
  24. data/lib/slipway/commands/apply.rb +172 -0
  25. data/lib/slipway/commands/base.rb +50 -0
  26. data/lib/slipway/commands/config.rb +73 -0
  27. data/lib/slipway/commands/create.rb +218 -0
  28. data/lib/slipway/commands/delete.rb +82 -0
  29. data/lib/slipway/commands/describe.rb +74 -0
  30. data/lib/slipway/commands/diff.rb +122 -0
  31. data/lib/slipway/commands/edit.rb +130 -0
  32. data/lib/slipway/commands/explain.rb +97 -0
  33. data/lib/slipway/commands/fetch.rb +112 -0
  34. data/lib/slipway/commands/from_dir.rb +141 -0
  35. data/lib/slipway/commands/get.rb +167 -0
  36. data/lib/slipway/commands/label.rb +114 -0
  37. data/lib/slipway/commands/manual.rb +67 -0
  38. data/lib/slipway/commands/options.rb +73 -0
  39. data/lib/slipway/commands/results.rb +57 -0
  40. data/lib/slipway/commands/rollout.rb +114 -0
  41. data/lib/slipway/commands/rollout_spec.rb +99 -0
  42. data/lib/slipway/commands/rollout_undo.rb +126 -0
  43. data/lib/slipway/commands/scope.rb +156 -0
  44. data/lib/slipway/commands/sync.rb +140 -0
  45. data/lib/slipway/commands.rb +54 -0
  46. data/lib/slipway/drift.rb +87 -0
  47. data/lib/slipway/editor.rb +71 -0
  48. data/lib/slipway/error.rb +27 -0
  49. data/lib/slipway/fetcher.rb +99 -0
  50. data/lib/slipway/field_selector.rb +86 -0
  51. data/lib/slipway/git/branch_name.rb +32 -0
  52. data/lib/slipway/git/commit.rb +13 -0
  53. data/lib/slipway/git/distance.rb +13 -0
  54. data/lib/slipway/git/errors.rb +125 -0
  55. data/lib/slipway/git/fake.rb +147 -0
  56. data/lib/slipway/git/fast_forward.rb +12 -0
  57. data/lib/slipway/git/fast_forwarding.rb +148 -0
  58. data/lib/slipway/git/fetch_result.rb +22 -0
  59. data/lib/slipway/git/move_back.rb +12 -0
  60. data/lib/slipway/git/reflog.rb +25 -0
  61. data/lib/slipway/git/repository.rb +288 -0
  62. data/lib/slipway/git/rolling_back.rb +98 -0
  63. data/lib/slipway/git/runner.rb +175 -0
  64. data/lib/slipway/git/status.rb +110 -0
  65. data/lib/slipway/git/url.rb +95 -0
  66. data/lib/slipway/git.rb +20 -0
  67. data/lib/slipway/inspector.rb +103 -0
  68. data/lib/slipway/labels.rb +126 -0
  69. data/lib/slipway/manifest.rb +265 -0
  70. data/lib/slipway/names.rb +22 -0
  71. data/lib/slipway/outcome.rb +45 -0
  72. data/lib/slipway/output/age.rb +70 -0
  73. data/lib/slipway/output/describe.rb +71 -0
  74. data/lib/slipway/output/explain.rb +75 -0
  75. data/lib/slipway/output/serializer.rb +35 -0
  76. data/lib/slipway/output/table.rb +67 -0
  77. data/lib/slipway/output.rb +28 -0
  78. data/lib/slipway/paths.rb +65 -0
  79. data/lib/slipway/plan.rb +227 -0
  80. data/lib/slipway/pool.rb +94 -0
  81. data/lib/slipway/resources.rb +91 -0
  82. data/lib/slipway/rollback.rb +236 -0
  83. data/lib/slipway/rollout_history.rb +69 -0
  84. data/lib/slipway/runtime.rb +65 -0
  85. data/lib/slipway/scanner.rb +54 -0
  86. data/lib/slipway/schema.rb +128 -0
  87. data/lib/slipway/selector.rb +146 -0
  88. data/lib/slipway/settings.rb +174 -0
  89. data/lib/slipway/state.rb +82 -0
  90. data/lib/slipway/store.rb +170 -0
  91. data/lib/slipway/syncer.rb +139 -0
  92. data/lib/slipway/version.rb +5 -0
  93. data/lib/slipway/views/group.rb +35 -0
  94. data/lib/slipway/views/project.rb +148 -0
  95. data/lib/slipway/views.rb +10 -0
  96. data/lib/slipway/yaml.rb +14 -0
  97. data/lib/slipway.rb +32 -0
  98. data/man/man1/slipway-api-resources.1 +53 -0
  99. data/man/man1/slipway-apply.1 +45 -0
  100. data/man/man1/slipway-completion.1 +29 -0
  101. data/man/man1/slipway-config-path.1 +20 -0
  102. data/man/man1/slipway-config-view.1 +25 -0
  103. data/man/man1/slipway-config.1 +22 -0
  104. data/man/man1/slipway-create.1 +89 -0
  105. data/man/man1/slipway-delete.1 +46 -0
  106. data/man/man1/slipway-describe.1 +95 -0
  107. data/man/man1/slipway-diff.1 +120 -0
  108. data/man/man1/slipway-edit.1 +34 -0
  109. data/man/man1/slipway-explain.1 +36 -0
  110. data/man/man1/slipway-fetch.1 +77 -0
  111. data/man/man1/slipway-get.1 +155 -0
  112. data/man/man1/slipway-help.1 +19 -0
  113. data/man/man1/slipway-label.1 +53 -0
  114. data/man/man1/slipway-man.1 +36 -0
  115. data/man/man1/slipway-rollout-history.1 +28 -0
  116. data/man/man1/slipway-rollout-pause.1 +21 -0
  117. data/man/man1/slipway-rollout-resume.1 +21 -0
  118. data/man/man1/slipway-rollout-undo.1 +73 -0
  119. data/man/man1/slipway-rollout-unpin.1 +21 -0
  120. data/man/man1/slipway-rollout.1 +36 -0
  121. data/man/man1/slipway-sync.1 +90 -0
  122. data/man/man1/slipway-version.1 +17 -0
  123. data/man/man1/slipway.1 +243 -0
  124. 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