-
Notifications
You must be signed in to change notification settings - Fork 9.3k
/
stash.ts
229 lines (199 loc) · 7.05 KB
/
stash.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
import { GitError as DugiteError } from 'dugite'
import { git } from '.'
import { Repository } from '../../models/repository'
import {
IStashEntry,
StashedChangesLoadStates,
StashedFileChanges,
} from '../../models/stash-entry'
import { CommittedFileChange } from '../../models/status'
import { GitError } from './core'
import { parseChangedFiles } from './log'
export const DesktopStashEntryMarker = '!!GitHub_Desktop'
/**
* RegEx for determining if a stash entry is created by Desktop
*
* This is done by looking for a magic string with the following
* format: `!!GitHub_Desktop<branch>`
*/
const desktopStashEntryMessageRe = /!!GitHub_Desktop<(.+)>$/
/**
* Get the list of stash entries created by Desktop in the current repository
* using the default ordering of `git stash list` (i.e., LIFO ordering).
*/
export async function getDesktopStashEntries(
repository: Repository
): Promise<ReadonlyArray<IStashEntry>> {
const delimiter = '1F'
const delimiterString = String.fromCharCode(parseInt(delimiter, 16))
const format = ['%gd', '%H', '%gs'].join(`%x${delimiter}`)
const result = await git(
['log', '-g', '-z', `--pretty=${format}`, 'refs/stash'],
repository.path,
'getStashEntries',
{
successExitCodes: new Set([0, 128]),
}
)
// There's no refs/stashes reflog in the repository or it's not
// even a repository. In either case we don't care
if (result.exitCode === 128) {
return []
}
const stashEntries: Array<IStashEntry> = []
const files: StashedFileChanges = { kind: StashedChangesLoadStates.NotLoaded }
for (const line of result.stdout.split('\0')) {
const pieces = line.split(delimiterString)
if (pieces.length === 3) {
const [name, stashSha, message] = pieces
const branchName = extractBranchFromMessage(message)
if (branchName !== null) {
stashEntries.push({ name, branchName, stashSha, files })
}
}
}
return stashEntries
}
/**
* Returns the last Desktop created stash entry for the given branch
*/
export async function getLastDesktopStashEntryForBranch(
repository: Repository,
branchName: string
) {
const entries = await getDesktopStashEntries(repository)
// Since stash objects are returned in a LIFO manner, the first
// entry found is guaranteed to be the last entry created
return entries.find(stash => stash.branchName === branchName) || null
}
/** Creates a stash entry message that idicates the entry was created by Desktop */
export function createDesktopStashMessage(branchName: string) {
return `${DesktopStashEntryMarker}<${branchName}>`
}
/**
* Stash the working directory changes for the current branch
*/
export async function createDesktopStashEntry(
repository: Repository,
branchName: string
) {
const message = createDesktopStashMessage(branchName)
const args = ['stash', 'push', '--include-untracked', '-m', message]
const result = await git(args, repository.path, 'createStashEntry', {
successExitCodes: new Set<number>([0, 1]),
})
if (result.exitCode === 1) {
// search for any line starting with `error:` - /m here to ensure this is
// applied to each line, without needing to split the text
const errorPrefixRe = /^error: /m
const matches = errorPrefixRe.exec(result.stderr)
if (matches !== null && matches.length > 0) {
// rethrow, because these messages should prevent the stash from being created
throw new GitError(result, args)
}
// if no error messages were emitted by Git, we should log but continue because
// a valid stash was created and this should not interfere with the checkout
log.info(
`[createDesktopStashEntry] a stash was created successfully but exit code ${
result.exitCode
} reported. stderr: ${result.stderr}`
)
}
}
async function getStashEntryMatchingSha(repository: Repository, sha: string) {
const stashEntries = await getDesktopStashEntries(repository)
return stashEntries.find(e => e.stashSha === sha) || null
}
/**
* Removes the given stash entry if it exists
*
* @param stashSha the SHA that identifies the stash entry
*/
export async function dropDesktopStashEntry(
repository: Repository,
stashSha: string
) {
const entryToDelete = await getStashEntryMatchingSha(repository, stashSha)
if (entryToDelete !== null) {
const args = ['stash', 'drop', entryToDelete.name]
await git(args, repository.path, 'dropStashEntry')
}
}
/**
* Pops the stash entry identified by matching `stashSha` to its commit hash.
*
* To see the commit hash of stash entry, run
* `git log -g refs/stash --pretty="%nentry: %gd%nsubject: %gs%nhash: %H%n"`
* in a repo with some stash entries.
*/
export async function popStashEntry(
repository: Repository,
stashSha: string
): Promise<void> {
// ignoring these git errors for now, this will change when we start
// implementing the stash conflict flow
const expectedErrors = new Set<DugiteError>([DugiteError.MergeConflicts])
const stashToPop = await getStashEntryMatchingSha(repository, stashSha)
if (stashToPop !== null) {
const args = ['stash', 'pop', `${stashToPop.name}`]
await git(args, repository.path, 'popStashEntry', { expectedErrors })
}
}
function extractBranchFromMessage(message: string): string | null {
const match = desktopStashEntryMessageRe.exec(message)
return match === null || match[1].length === 0 ? null : match[1]
}
/**
* Get the files that were changed in the given stash commit.
*
* This is different than `getChangedFiles` because stashes
* have _3 parents(!!!)_
*/
export async function getStashedFiles(
repository: Repository,
stashSha: string
): Promise<ReadonlyArray<CommittedFileChange>> {
const [trackedFiles, untrackedFiles] = await Promise.all([
getChangedFilesWithinStash(repository, stashSha),
getChangedFilesWithinStash(repository, `${stashSha}^3`),
])
const files = new Map<string, CommittedFileChange>()
trackedFiles.forEach(x => files.set(x.path, x))
untrackedFiles.forEach(x => files.set(x.path, x))
return [...files.values()].sort((x, y) => x.path.localeCompare(y.path))
}
/**
* Same thing as `getChangedFiles` but with extra handling for 128 exit code
* (which happens if the commit's parent is not valid)
*
* **TODO:** merge this with `getChangedFiles` in `log.ts`
*/
async function getChangedFilesWithinStash(repository: Repository, sha: string) {
// opt-in for rename detection (-M) and copies detection (-C)
// this is equivalent to the user configuring 'diff.renames' to 'copies'
// NOTE: order here matters - doing -M before -C means copies aren't detected
const args = [
'log',
sha,
'-C',
'-M',
'-m',
'-1',
'--no-show-signature',
'--first-parent',
'--name-status',
'--format=format:',
'-z',
'--',
]
const result = await git(args, repository.path, 'getChangedFilesForStash', {
// if this fails, its most likely
// because there weren't any untracked files,
// and that's okay!
successExitCodes: new Set([0, 128]),
})
if (result.exitCode === 0 && result.stdout.length > 0) {
return parseChangedFiles(result.stdout, sha)
}
return []
}