/
diff.ts
134 lines (128 loc) · 4.99 KB
/
diff.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
/*
* Copyright © 2020 Atomist, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { LocalProject } from "@atomist/automation-client/lib/project/local/LocalProject";
import { execPromise } from "@atomist/automation-client/lib/util/child_process";
import { SdmGoalEvent } from "../../../api/goal/SdmGoalEvent";
import { ProgressLog } from "../../../spi/log/ProgressLog";
import { ChangeType } from "./change";
/**
* Glob pattern used to identify Kubernetes spec files in a
* repository. It only matched JSON and YAML files at the root of the
* repository.
*/
export const k8sSpecGlob = "*.@(json|yaml|yml)";
/**
* Regular expression used to identify Kubernetes spec files in a
* repository. It only matched JSON and YAML files at the root of the
* repository.
*/
export const k8sSpecRegExp = /^[^/]+\.(?:json|ya?ml)$/;
/** Container for changes in a commit. */
export interface PushDiff {
/** Whether to apply or delete the change. */
change: ChangeType;
/** Path to spec relative to the project base directory. */
path: string;
/** Git SHA of change. */
sha: string;
}
/**
* Determine all changed Kubernetes resource spec files in a push. A
* file is considered a Kubernetes resource spec if it matches
* [[k8sSpecRegExp]]. Changes are returned in commit order, with the
* commit order unchanged from the push event sent by cortex, which it
* typically chronologically with the oldest commit first. Within a
* commit, the changes are sorted first by operation, with deletes
* before applies, and then by path using `sort(localCompare)`.
* Deletes are sorted first because renames are processed as adds and
* deletes and we want to avoid a rename resulting in a resource being
* deleted. If you want to control the order of operations, spread
* the operations across multiple commits in the same push.
*
* @param project project with the changed Kubernetes resource specs.
* @param push git push with changes
* @param tag git commit message tag indicating automated commits that should be ignored
* @param log goal execution progress log
* @return sorted resource spec changes
*/
export async function diffPush(project: LocalProject, push: SdmGoalEvent["push"], tag: string, log: ProgressLog): Promise<PushDiff[]> {
const changes: PushDiff[] = [];
const commits = push.commits.filter(c => !c.message.includes(tag));
for (const commit of commits) {
try {
const sha = commit.sha;
const args = ["diff", "-z", "--no-renames", "--name-status", "--diff-filter=ADM", `${sha}~1`, sha, "--"];
const opts = { cwd: project.baseDir };
const diffResult = await execPromise("git", args, opts);
const newChanges = parseNameStatusDiff(sha, diffResult.stdout);
changes.push(...newChanges);
} catch (e) {
e.message = `Failed to diff commit ${commit.sha}, skipping: ${e.message}`;
log.write(e.message);
}
}
return changes;
}
/**
* Convert output of `git diff -z --name-status` to a list of file
* changes. The returned changes are sorted by file path.
*
* @param sha git commit SHA
* @param diff output from git diff command
* @return sorted resource spec changes
*/
export function parseNameStatusDiff(sha: string, diff: string): PushDiff[] {
const changes: PushDiff[] = [];
const diffContent = diff.trim().replace(/\0$/, "");
if (!diffContent) {
return changes;
}
const fields = diffContent.split("\0");
for (let i = 0; i < fields.length; i += 2) {
const f = fields[i];
if (!f) {
throw new Error(`Empty git diff status`);
}
const path = fields[i + 1];
if (!path) {
throw new Error(`Missing path from git diff status: ${f}`);
}
if (!k8sSpecRegExp.test(path)) {
continue;
}
if (f === "A" || f.startsWith("M")) {
changes.push({ change: "apply", path, sha });
} else if (f === "D") {
changes.push({ change: "delete", path, sha });
} else {
throw new Error(`Unexpected git diff status for path '${path}' in commit ${sha}: ${f}`);
}
}
return changes.sort(compareDiff);
}
/**
* Sort "delete" changes before "apply". Within each category, sort
* by path.
*/
function compareDiff(a: PushDiff, b: PushDiff): number {
if (a.change === b.change) {
return a.path.localeCompare(b.path);
} else if (a.change === "delete") {
return -1;
} else {
return 1;
}
}