forked from cfengine/create-pull-request
/
create-or-update-branch.ts
270 lines (247 loc) · 8.54 KB
/
create-or-update-branch.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
import * as core from '@actions/core'
import {GitCommandManager} from './git-command-manager'
import {v4 as uuidv4} from 'uuid'
const CHERRYPICK_EMPTY =
'The previous cherry-pick is now empty, possibly due to conflict resolution.'
const NOTHING_TO_COMMIT = 'nothing to commit, working tree clean'
export enum WorkingBaseType {
Branch = 'branch',
Commit = 'commit'
}
export async function getWorkingBaseAndType(
git: GitCommandManager
): Promise<[string, WorkingBaseType]> {
const symbolicRefResult = await git.exec(
['symbolic-ref', 'HEAD', '--short'],
true
)
if (symbolicRefResult.exitCode == 0) {
// A ref is checked out
return [symbolicRefResult.stdout.trim(), WorkingBaseType.Branch]
} else {
// A commit is checked out (detached HEAD)
const headSha = await git.revParse('HEAD')
return [headSha, WorkingBaseType.Commit]
}
}
export async function tryFetch(
git: GitCommandManager,
remote: string,
branch: string
): Promise<boolean> {
try {
await git.fetch([`${branch}:refs/remotes/${remote}/${branch}`], remote, [
'--force'
])
return true
} catch {
return false
}
}
// Return true if branch2 is ahead of branch1
async function isAhead(
git: GitCommandManager,
branch1: string,
branch2: string
): Promise<boolean> {
const result = await git.revList(
[`${branch1}...${branch2}`],
['--right-only', '--count']
)
return Number(result) > 0
}
// Return true if branch2 is behind branch1
async function isBehind(
git: GitCommandManager,
branch1: string,
branch2: string
): Promise<boolean> {
const result = await git.revList(
[`${branch1}...${branch2}`],
['--left-only', '--count']
)
return Number(result) > 0
}
// Return true if branch2 is even with branch1
async function isEven(
git: GitCommandManager,
branch1: string,
branch2: string
): Promise<boolean> {
return (
!(await isAhead(git, branch1, branch2)) &&
!(await isBehind(git, branch1, branch2))
)
}
function splitLines(multilineString: string): string[] {
return multilineString
.split('\n')
.map(s => s.trim())
.filter(x => x !== '')
}
export async function createOrUpdateBranch(
git: GitCommandManager,
commitMessage: string,
base: string,
branch: string,
branchRemoteName: string,
signoff: boolean,
addPaths: string[]
): Promise<CreateOrUpdateBranchResult> {
// Get the working base.
// When a ref, it may or may not be the actual base.
// When a commit, we must rebase onto the actual base.
const [workingBase, workingBaseType] = await getWorkingBaseAndType(git)
core.info(`Working base is ${workingBaseType} '${workingBase}'`)
if (workingBaseType == WorkingBaseType.Commit && !base) {
throw new Error(`When in 'detached HEAD' state, 'base' must be supplied.`)
}
// If the base is not specified it is assumed to be the working base.
base = base ? base : workingBase
const baseRemote = 'origin'
// Set the default return values
const result: CreateOrUpdateBranchResult = {
action: 'none',
base: base,
hasDiffWithBase: false,
headSha: ''
}
// Save the working base changes to a temporary branch
const tempBranch = uuidv4()
await git.checkout(tempBranch, 'HEAD')
// Commit any uncommitted changes
if (await git.isDirty(true, addPaths)) {
core.info('Uncommitted changes found. Adding a commit.')
const aopts = ['add']
if (addPaths.length > 0) {
aopts.push(...['--', ...addPaths])
} else {
aopts.push('-A')
}
await git.exec(aopts, true)
const popts = ['-m', commitMessage]
if (signoff) {
popts.push('--signoff')
}
const commitResult = await git.commit(popts, true)
// 'nothing to commit' can occur when core.autocrlf is set to true
if (
commitResult.exitCode != 0 &&
!commitResult.stdout.includes(NOTHING_TO_COMMIT)
) {
throw new Error(`Unexpected error: ${commitResult.stderr}`)
}
}
// Remove uncommitted tracked and untracked changes
await git.exec(['reset', '--hard'])
await git.exec(['clean', '-f', '-d'])
// Perform fetch and reset the working base
// Commits made during the workflow will be removed
if (workingBaseType == WorkingBaseType.Branch) {
core.info(`Resetting working base branch '${workingBase}'`)
if (branchRemoteName == 'fork') {
// If pushing to a fork we must fetch with 'unshallow' to avoid the following error on git push
// ! [remote rejected] HEAD -> tests/push-branch-to-fork (shallow update not allowed)
await git.fetch([`${workingBase}:${workingBase}`], baseRemote, [
'--force'
])
} else {
// If the remote is 'origin' we can git reset
await git.checkout(workingBase)
await git.exec(['reset', '--hard', `${baseRemote}/${workingBase}`])
}
}
// If the working base is not the base, rebase the temp branch commits
// This will also be true if the working base type is a commit
if (workingBase != base) {
core.info(
`Rebasing commits made to ${workingBaseType} '${workingBase}' on to base branch '${base}'`
)
// Checkout the actual base
await git.fetch([`${base}:${base}`], baseRemote, ['--force'])
await git.checkout(base)
// Cherrypick commits from the temporary branch starting from the working base
const commits = await git.revList(
[`${workingBase}..${tempBranch}`, '.'],
['--reverse']
)
for (const commit of splitLines(commits)) {
const result = await git.cherryPick(
['--strategy=recursive', '--strategy-option=theirs', commit],
true
)
if (result.exitCode != 0 && !result.stderr.includes(CHERRYPICK_EMPTY)) {
throw new Error(`Unexpected error: ${result.stderr}`)
}
}
// Reset the temp branch to the working index
await git.checkout(tempBranch, 'HEAD')
// Reset the base
await git.fetch([`${base}:${base}`], baseRemote, ['--force'])
}
// Try to fetch the pull request branch
if (!(await tryFetch(git, branchRemoteName, branch))) {
// The pull request branch does not exist
core.info(`Pull request branch '${branch}' does not exist yet.`)
// Create the pull request branch
await git.checkout(branch, tempBranch)
// Check if the pull request branch is ahead of the base
result.hasDiffWithBase = await isAhead(git, base, branch)
if (result.hasDiffWithBase) {
result.action = 'created'
core.info(`Created branch '${branch}'`)
} else {
core.info(
`Branch '${branch}' is not ahead of base '${base}' and will not be created`
)
}
} else {
// The pull request branch exists
core.info(
`Pull request branch '${branch}' already exists as remote branch '${branchRemoteName}/${branch}'`
)
// Checkout the pull request branch
await git.checkout(branch)
// Reset the branch if one of the following conditions is true.
// - If the branch differs from the recreated temp branch.
// - If the recreated temp branch is not ahead of the base. This means there will be
// no pull request diff after the branch is reset. This will reset any undeleted
// branches after merging. In particular, it catches a case where the branch was
// squash merged but not deleted. We need to reset to make sure it doesn't appear
// to have a diff with the base due to different commits for the same changes.
// For changes on base this reset is equivalent to a rebase of the pull request branch.
if (
(await git.hasDiff([`${branch}..${tempBranch}`])) ||
!(await isAhead(git, base, tempBranch))
) {
core.info(`Resetting '${branch}'`)
// Alternatively, git switch -C branch tempBranch
await git.checkout(branch, tempBranch)
}
// Check if the pull request branch has been updated
// If the branch was reset or updated it will be ahead
// It may be behind if a reset now results in no diff with the base
if (!(await isEven(git, `${branchRemoteName}/${branch}`, branch))) {
result.action = 'updated'
core.info(`Updated branch '${branch}'`)
} else {
result.action = 'not-updated'
core.info(
`Branch '${branch}' is even with its remote and will not be updated`
)
}
// Check if the pull request branch is ahead of the base
result.hasDiffWithBase = await isAhead(git, base, branch)
}
// Get the pull request branch SHA
result.headSha = await git.revParse('HEAD')
// Delete the temporary branch
await git.exec(['branch', '--delete', '--force', tempBranch])
return result
}
interface CreateOrUpdateBranchResult {
action: string
base: string
hasDiffWithBase: boolean
headSha: string
}