1 module bisect;
2 
3 import core.thread;
4 
5 import std.algorithm;
6 import std.exception;
7 import std.file;
8 import std.getopt : getopt;
9 import std.path;
10 import std.process;
11 import std.range;
12 import std..string;
13 
14 import ae.sys.file;
15 import ae.sys.git;
16 import ae.utils.math;
17 import ae.utils.sini;
18 
19 import common;
20 import config;
21 import repo;
22 
23 enum EXIT_UNTESTABLE = 125;
24 
25 string bisectConfigFile;
26 struct BisectConfig
27 {
28 	string bad, good;
29 	bool reverse;
30 	string tester;
31 	bool bisectBuild;
32 	bool bisectBuildTest;
33 
34 	DiggerManager.Config.Build* build;
35 	DiggerManager.Config.Local* local;
36 
37 	string[string] environment;
38 }
39 BisectConfig bisectConfig;
40 
41 /// Final build directory for bisect tests.
42 alias currentDir = subDir!"current";
43 
44 int doBisect(bool noVerify, string bisectConfigFile, string[] bisectConfigLines)
45 {
46 	bisectConfig.build = &d.config.build;
47 	bisectConfig.local = &d.config.local;
48 	if (bisectConfigFile)
49 	{
50 		log("Loading bisect configuration from " ~ bisectConfigFile);
51 		bisectConfigFile
52 			.readText()
53 			.splitLines()
54 			.parseIniInto(bisectConfig);
55 	}
56 	else
57 		log("No bisect.ini file specified! Using options from command-line only.");
58 	bisectConfigLines.parseIniInto(bisectConfig);
59 
60 	void ensureDefault(ref string var, string which, string def)
61 	{
62 		if (!var)
63 		{
64 			log("No %s revision specified, assuming '%s'".format(which, def));
65 			var = def;
66 		}
67 	}
68 	ensureDefault(bisectConfig.bad , "bad" , "master");
69 	ensureDefault(bisectConfig.good, "good", "@ 1 month ago");
70 
71 	if (bisectConfig.bisectBuildTest)
72 	{
73 		bisectConfig.bisectBuild = true;
74 		d.config.local.cache = "none";
75 	}
76 	if (bisectConfig.bisectBuild)
77 		enforce(!bisectConfig.tester, "bisectBuild and specifying a test command are mutually exclusive");
78 	enforce(bisectConfig.tester || bisectConfig.bisectBuild, "No tester specified (and bisectBuild is false)");
79 
80 	d.getMetaRepo().needRepo();
81 	auto repo = &d.getMetaRepo().git;
82 
83 	d.needUpdate();
84 
85 	void test(bool good, string rev)
86 	{
87 		auto name = good ? "GOOD" : "BAD";
88 		log("Sanity-check, testing %s revision %s...".format(name, rev));
89 		auto result = doBisectStep(rev);
90 		enforce(result != EXIT_UNTESTABLE,
91 			"%s revision %s is not testable"
92 			.format(name, rev));
93 		enforce(!result == good,
94 			"%s revision %s is not correct (exit status is %d)"
95 			.format(name, rev, result));
96 	}
97 
98 	if (!noVerify)
99 	{
100 		auto good = getRev!true();
101 		auto bad = getRev!false();
102 
103 		enforce(good != bad, "Good and bad revisions are both " ~ bad);
104 
105 		auto commonAncestor = repo.query("merge-base", good, bad);
106 		if (bisectConfig.reverse)
107 		{
108 			enforce(good != commonAncestor, "Bad commit is newer than good commit (and reverse search is enabled)");
109 			test(false, bad);
110 			test(true, good);
111 		}
112 		else
113 		{
114 			enforce(bad  != commonAncestor, "Good commit is newer than bad commit");
115 			test(true, good);
116 			test(false, bad);
117 		}
118 	}
119 
120 	auto p0 = getRev!true();  // good
121 	auto p1 = getRev!false(); // bad
122 	if (bisectConfig.reverse)
123 		swap(p0, p1);
124 
125 	auto cacheState = d.getCacheState([p0, p1]);
126 	bool[string] untestable;
127 
128 	bisectLoop:
129 	while (true)
130 	{
131 		log("Finding shortest path between %s and %s...".format(p0, p1));
132 		auto fullPath = repo.pathBetween(p0, p1); // closed interval
133 		enforce(fullPath.length >= 2 && fullPath[0].commit == p0 && fullPath[$-1].commit == p1,
134 			"Bad path calculation result");
135 		auto path = fullPath[1..$-1].map!(step => step.commit).array; // open interval
136 		log("%d commits (about %d tests) remaining.".format(path.length, ilog2(path.length+1)));
137 
138 		if (!path.length)
139 		{
140 			assert(fullPath.length == 2);
141 			auto p = fullPath[1].downwards ? p0 : p1;
142 			log("%s is the first %s commit".format(p, bisectConfig.reverse ? "good" : "bad"));
143 			repo.run("--no-pager", "show", p);
144 			log("Bisection completed successfully.");
145 			return 0;
146 		}
147 
148 		log("(%d total, %d cached, %d untestable)".format(
149 			path.length,
150 			path.filter!(commit => cacheState.get(commit, false)).walkLength,
151 			path.filter!(commit => commit in untestable).walkLength,
152 		));
153 
154 		// First try all cached commits in the range (middle-most first).
155 		// Afterwards, do a binary-log search across the commit range for a testable commit.
156 		auto order = chain(
157 			path.radial     .filter!(commit =>  cacheState.get(commit, false)),
158 			path.binaryOrder.filter!(commit => !cacheState.get(commit, false))
159 		).filter!(commit => commit !in untestable).array;
160 
161 		foreach (i, p; order)
162 		{
163 			auto result = doBisectStep(p);
164 			if (result == EXIT_UNTESTABLE)
165 			{
166 				log("Commit %s (%d/%d) is untestable.".format(p, i+1, order.length));
167 				untestable[p] = true;
168 				continue;
169 			}
170 
171 			if (bisectConfig.reverse)
172 				result = result ? 0 : 1;
173 
174 			if (result == 0) // good
175 				p0 = p;
176 			else
177 				p1 = p;
178 
179 			continue bisectLoop;
180 		}
181 
182 		log("There are only untestable commits left to bisect.");
183 		log("The first %s commit could be any of:".format(bisectConfig.reverse ? "good" : "bad"));
184 		foreach (p; path ~ [p1])
185 			log(p);
186 		throw new Exception("We cannot bisect more!");
187 	}
188 
189 	assert(false);
190 }
191 
192 struct BisectStep
193 {
194 	string commit;
195 	bool downwards; // on the way to the common ancestor
196 }
197 
198 BisectStep[] pathBetween(Repository* repo, string p0, string p1)
199 {
200 	auto commonAncestor = repo.query("merge-base", p0, p1);
201 	return chain(
202 		repo.commitsBetween(commonAncestor, p0).retro.map!(commit => BisectStep(commit, true )),
203 		commonAncestor.only                          .map!(commit => BisectStep(commit, true )),
204 		repo.commitsBetween(commonAncestor, p1)      .map!(commit => BisectStep(commit, false)),
205 	).array;
206 }
207 
208 string[] commitsBetween(Repository* repo, string p0, string p1)
209 {
210 	return repo.query("log", "--reverse", "--pretty=format:%H", p0 ~ ".." ~ p1).splitLines();
211 }
212 
213 /// Reorders [1, 2, ..., 98, 99]
214 /// into [50, 25, 75, 13, 38, 63, 88, ...]
215 T[] binaryOrder(T)(T[] items)
216 {
217 	auto n = items.length;
218 	assert(n);
219 	auto seen = new bool[n];
220 	auto result = new T[n];
221 	size_t c = 0;
222 
223 	foreach (p; 0..30)
224 		foreach (i; 0..1<<p)
225 		{
226 			auto x = cast(size_t)(n/(2<<p) + ulong(n+1)*i/(1<<p));
227 			if (x >= n || seen[x])
228 				continue;
229 			seen[x] = true;
230 			result[c++] = items[x];
231 			if (c == n)
232 				return result;
233 		}
234 	assert(false);
235 }
236 
237 unittest
238 {
239 	assert(iota(1, 7+1).array.binaryOrder.equal([4, 2, 6, 1, 3, 5, 7]));
240 	assert(iota(1, 100).array.binaryOrder.startsWith([50, 25, 75, 13, 38, 63, 88]));
241 }
242 
243 int doBisectStep(string rev)
244 {
245 	log("Testing revision: " ~ rev);
246 
247 	try
248 	{
249 		if (currentDir.exists)
250 		{
251 			version (Windows)
252 			{
253 				try
254 					currentDir.rmdirRecurse();
255 				catch (Exception e)
256 				{
257 					log("Failed to clean up %s: %s".format(currentDir, e.msg));
258 					Thread.sleep(500.msecs);
259 					log("Retrying...");
260 					currentDir.rmdirRecurse();
261 				}
262 			}
263 			else
264 				currentDir.rmdirRecurse();
265 		}
266 
267 		auto state = d.begin(rev);
268 
269 		scope (exit)
270 			if (d.buildDir.exists)
271 				rename(d.buildDir, currentDir);
272 
273 		d.build(state);
274 	}
275 	catch (Exception e)
276 	{
277 		log("Build failed: " ~ e.toString());
278 		if (bisectConfig.bisectBuild && !bisectConfig.bisectBuildTest)
279 			return 1;
280 		return EXIT_UNTESTABLE;
281 	}
282 
283 	if (bisectConfig.bisectBuild)
284 	{
285 		log("Build successful.");
286 
287 		if (bisectConfig.bisectBuildTest)
288 		{
289 			try
290 				d.test();
291 			catch (Exception e)
292 			{
293 				log("Tests failed: " ~ e.toString());
294 				return 1;
295 			}
296 			log("Tests successful.");
297 		}
298 
299 		return 0;
300 	}
301 
302 	string[string] env = d.getBaseEnvironment();
303 	d.applyEnv(env, bisectConfig.environment);
304 
305 	auto oldPath = environment["PATH"];
306 	scope(exit) environment["PATH"] = oldPath;
307 
308 	// Add the final DMD to the environment PATH
309 	env["PATH"] = buildPath(currentDir, "bin").absolutePath() ~ pathSeparator ~ env["PATH"];
310 	environment["PATH"] = env["PATH"];
311 
312 	// Use host HOME for the test command
313 	env["HOME"] = environment.get("HOME");
314 
315 	// For bisecting bootstrapping issues - allows passing the revision to another Digger instance
316 	env["DIGGER_REVISION"] = rev;
317 
318 	d.logProgress("Running test command...");
319 	auto result = spawnShell(bisectConfig.tester, env, Config.newEnv).wait();
320 	d.logProgress("Test command exited with status %s (%s).".format(result, result==0 ? "GOOD" : result==EXIT_UNTESTABLE ? "UNTESTABLE" : "BAD"));
321 	return result;
322 }
323 
324 /// Returns SHA-1 of the initial search points.
325 string getRev(bool good)()
326 {
327 	static string result;
328 	if (!result)
329 	{
330 		auto rev = good ? bisectConfig.good : bisectConfig.bad;
331 		result = parseRev(rev);
332 		log("Resolved %s revision `%s` to %s.".format(good ? "GOOD" : "BAD", rev, result));
333 	}
334 	return result;
335 }
336 
337 struct CommitRange
338 {
339 	uint startTime; /// first bad commit
340 	uint endTime;   /// first good commit
341 }
342 /// Known unbuildable time ranges
343 const CommitRange[] badCommits =
344 [
345 	{ 1342243766, 1342259226 }, // Messed up DMD make files
346 	{ 1317625155, 1319346272 }, // Missing std.stdio import in std.regex
347 ];
348 
349 /// Find the earliest revision that Digger can build.
350 /// Used during development to extend Digger's range.
351 int doDelve(bool inBisect)
352 {
353 	if (inBisect)
354 	{
355 		log("Invoked by git-bisect - performing bisect step.");
356 
357 		import std.conv;
358 		d.getMetaRepo().needRepo();
359 		auto rev = d.getMetaRepo().getRef("BISECT_HEAD");
360 		auto t = d.getMetaRepo().git.query("log", "-n1", "--pretty=format:%ct", rev).to!int();
361 		foreach (r; badCommits)
362 			if (r.startTime <= t && t < r.endTime)
363 			{
364 				log("This revision is known to be unbuildable, skipping.");
365 				return EXIT_UNTESTABLE;
366 			}
367 
368 		d.cacheFailures = false;
369 		//d.config.build = bisectConfig.build; // TODO
370 		auto state = d.begin(rev);
371 		try
372 		{
373 			d.build(state);
374 			return 1;
375 		}
376 		catch (Exception e)
377 		{
378 			log("Build failed: " ~ e.toString());
379 			return 0;
380 		}
381 	}
382 	else
383 	{
384 		d.getMetaRepo.needRepo();
385 		auto root = d.getMetaRepo().git.query("log", "--pretty=format:%H", "--reverse", "master").splitLines()[0];
386 		d.getMetaRepo().git.run(["bisect", "start", "--no-checkout", "master", root]);
387 		d.getMetaRepo().git.run("bisect", "run",
388 			thisExePath,
389 			"--dir", getcwd(),
390 			"--config-file", opts.configFile,
391 			"delve", "--in-bisect",
392 		);
393 		return 0;
394 	}
395 }