1 /**
2  * Code to manage a D component repository.
3  *
4  * License:
5  *   This Source Code Form is subject to the terms of
6  *   the Mozilla Public License, v. 2.0. If a copy of
7  *   the MPL was not distributed with this file, You
8  *   can obtain one at http://mozilla.org/MPL/2.0/.
9  *
10  * Authors:
11  *   Vladimir Panteleev <vladimir@thecybershadow.net>
12  */
13 
14 module ae.sys.d.repo;
15 
16 import std.algorithm;
17 import std.conv : text;
18 import std.datetime : SysTime;
19 import std.exception;
20 import std.file;
21 import std.process : environment;
22 import std.range;
23 import std.regex;
24 import std.string;
25 import std.path;
26 
27 import ae.sys.git;
28 import ae.utils.exception;
29 import ae.utils.json;
30 import ae.utils.regex;
31 import ae.utils.time : StdTime;
32 
33 /// Base class for a managed repository.
34 class ManagedRepository
35 {
36 	/// Git repository we manage.
37 	public Repository git;
38 
39 	/// Should we fetch the latest stuff?
40 	public bool offline;
41 
42 	/// Verify working tree state to make sure we don't clobber user changes?
43 	public bool verify;
44 
45 	/// Ensure we have a repository.
46 	public void needRepo()
47 	{
48 		assert(git.path, "No repository");
49 	}
50 
51 	public @property string name() { needRepo(); return git.path.baseName; }
52 
53 	// Head
54 
55 	/// Ensure the repository's HEAD is as indicated.
56 	public void needHead(string hash)
57 	{
58 		needClean();
59 		if (getHead() == hash)
60 			return;
61 
62 		try
63 			performCheckout(hash);
64 		catch (Exception e)
65 		{
66 			log("Error checking out %s: %s".format(hash, e));
67 
68 			// Might be a GC-ed merge. Try to recreate the merge
69 			auto hit = mergeCache.find!(entry => entry.result == hash)();
70 			enforce(!hit.empty, "Unknown hash %s".format(hash));
71 			performMerge(hit.front.base, hit.front.branch, hit.front.revert, hit.front.mainline);
72 			enforce(getHead() == hash, "Unexpected merge result: expected %s, got %s".format(hash, getHead()));
73 		}
74 	}
75 
76 	private string currentHead = null;
77 
78 	/// Returns the SHA1 of the given named ref.
79 	public string getRef(string name)
80 	{
81 		return git.query("rev-parse", name);
82 	}
83 
84 	/// Return the commit the repository HEAD is pointing at.
85 	/// Cache the result.
86 	public string getHead()
87 	{
88 		if (!currentHead)
89 			currentHead = getRef("HEAD");
90 
91 		return currentHead;
92 	}
93 
94 	protected void performCheckout(string hash)
95 	{
96 		needClean();
97 		needCommit(hash);
98 
99 		log("Checking out %s commit %s...".format(name, hash));
100 
101 		git.run("checkout", hash);
102 
103 		saveState();
104 		currentHead = hash;
105 	}
106 
107 	/// Ensure that the specified commit is fetched.
108 	protected void needCommit(string hash)
109 	{
110 		void check()
111 		{
112 			enforce(git.query(["cat-file", "-t", hash]) == "commit",
113 				"Unexpected object type");
114 		}
115 
116 		if (offline)
117 			check();
118 		else
119 		{
120 			try
121 				check();
122 			catch (Exception e)
123 			{
124 				log("Don't have commit " ~ hash ~ ", updating and retrying...");
125 				update();
126 				check();
127 			}
128 		}
129 	}
130 
131 	/// Update the remote.
132 	public void update()
133 	{
134 		if (!offline)
135 		{
136 			needRepo();
137 			log("Updating " ~ name ~ "...");
138 			git.run("-c", "fetch.recurseSubmodules=false", "remote", "update", "--prune");
139 			git.run("-c", "fetch.recurseSubmodules=false", "fetch", "--force", "--tags");
140 		}
141 	}
142 
143 	// Clean
144 
145 	bool clean = false;
146 
147 	/// Ensure the repository's working copy is clean.
148 	public void needClean()
149 	{
150 		if (clean)
151 			return;
152 		performCleanup();
153 		clean = true;
154 	}
155 
156 	private void performCleanup()
157 	{
158 		checkState();
159 		clearState();
160 
161 		log("Cleaning repository %s...".format(name));
162 		needRepo();
163 		try
164 		{
165 			git.run("reset", "--hard");
166 			git.run("clean", "--force", "--force" /*Sic*/, "-x", "-d", "--quiet");
167 		}
168 		catch (Exception e)
169 			throw new RepositoryCleanException(e.msg, e);
170 		saveState();
171 	}
172 
173 	// Merge cache
174 
175 	private static struct MergeInfo
176 	{
177 		string base, branch;
178 		bool revert = false;
179 		int mainline = 0;
180 		string result;
181 	}
182 	private alias MergeCache = MergeInfo[];
183 	private MergeCache mergeCacheData;
184 	private bool haveMergeCache;
185 
186 	private @property ref MergeCache mergeCache()
187 	{
188 		if (!haveMergeCache)
189 		{
190 			if (mergeCachePath.exists)
191 				mergeCacheData = mergeCachePath.readText().jsonParse!MergeCache;
192 			haveMergeCache = true;
193 		}
194 
195 		return mergeCacheData;
196 	}
197 
198 	private void saveMergeCache()
199 	{
200 		std.file.write(mergeCachePath(), toJson(mergeCache));
201 	}
202 
203 	private @property string mergeCachePath()
204 	{
205 		needRepo();
206 		return buildPath(git.gitDir, "ae-sys-d-mergecache.json");
207 	}
208 
209 	// Merge
210 
211 	private void setupGitEnv()
212 	{
213 		string[string] mergeEnv;
214 		foreach (person; ["AUTHOR", "COMMITTER"])
215 		{
216 			mergeEnv["GIT_%s_DATE".format(person)] = "Thu, 01 Jan 1970 00:00:00 +0000";
217 			mergeEnv["GIT_%s_NAME".format(person)] = "ae.sys.d";
218 			mergeEnv["GIT_%s_EMAIL".format(person)] = "ae.sys.d\x40thecybershadow.net";
219 		}
220 		foreach (k, v; mergeEnv)
221 			environment[k] = v;
222 		// TODO: restore environment
223 	}
224 
225 	/// Returns the hash of the merge between the base and branch commits.
226 	/// Performs the merge if necessary. Caches the result.
227 	public string getMerge(string base, string branch)
228 	{
229 		return getMergeImpl(base, branch, false, 0);
230 	}
231 
232 	/// Returns the resulting hash when reverting the branch from the base commit.
233 	/// Performs the revert if necessary. Caches the result.
234 	/// mainline is the 1-based mainline index (as per `man git-revert`),
235 	/// or 0 if commit is not a merge commit.
236 	public string getRevert(string base, string branch, int mainline)
237 	{
238 		return getMergeImpl(base, branch, true, mainline);
239 	}
240 
241 	private string getMergeImpl(string base, string branch, bool revert, int mainline)
242 	{
243 		auto hit = mergeCache.find!(entry =>
244 			entry.base == base &&
245 			entry.branch == branch &&
246 			entry.revert == revert &&
247 			entry.mainline == mainline)();
248 		if (!hit.empty)
249 			return hit.front.result;
250 
251 		performMerge(base, branch, revert, mainline);
252 
253 		auto head = getHead();
254 		mergeCache ~= MergeInfo(base, branch, revert, mainline, head);
255 		saveMergeCache();
256 		return head;
257 	}
258 
259 	private static const string mergeCommitMessage = "ae.sys.d merge";
260 	private static const string revertCommitMessage = "ae.sys.d revert";
261 
262 	// Performs a merge or revert.
263 	private void performMerge(string base, string branch, bool revert, int mainline)
264 	{
265 		needHead(base);
266 		currentHead = null;
267 
268 		log("%s %s into %s.".format(revert ? "Reverting" : "Merging", branch, base));
269 
270 		scope(exit) saveState();
271 
272 		scope (failure)
273 		{
274 			if (!revert)
275 			{
276 				log("Aborting merge...");
277 				git.run("merge", "--abort");
278 			}
279 			else
280 			{
281 				log("Aborting revert...");
282 				git.run("revert", "--abort");
283 			}
284 			clean = false;
285 		}
286 
287 		void doMerge()
288 		{
289 			setupGitEnv();
290 			if (!revert)
291 				git.run("merge", "--no-ff", "-m", mergeCommitMessage, branch);
292 			else
293 			{
294 				string[] args = ["revert", "--no-edit"];
295 				if (mainline)
296 					args ~= ["--mainline", text(mainline)];
297 				args ~= [branch];
298 				git.run(args);
299 			}
300 		}
301 
302 		if (git.path.baseName() == "dmd")
303 		{
304 			try
305 				doMerge();
306 			catch (Exception)
307 			{
308 				log("Merge failed. Attempting conflict resolution...");
309 				git.run("checkout", "--theirs", "test");
310 				git.run("add", "test");
311 				if (!revert)
312 					git.run("-c", "rerere.enabled=false", "commit", "-m", mergeCommitMessage);
313 				else
314 					git.run("revert", "--continue");
315 			}
316 		}
317 		else
318 			doMerge();
319 
320 		log("Merge successful.");
321 	}
322 
323 	/// Finds and returns the merge parents of the given merge commit.
324 	/// Queries the git repository if necessary. Caches the result.
325 	public MergeInfo getMergeInfo(string merge)
326 	{
327 		auto hit = mergeCache.find!(entry => entry.result == merge && !entry.revert)();
328 		if (!hit.empty)
329 			return hit.front;
330 
331 		auto parents = git.query(["log", "--pretty=%P", "-n", "1", merge]).split();
332 		enforce(parents.length > 1, "Not a merge: " ~ merge);
333 		enforce(parents.length == 2, "Too many parents: " ~ merge);
334 
335 		auto info = MergeInfo(parents[0], parents[1], false, 0, merge);
336 		mergeCache ~= info;
337 		return info;
338 	}
339 
340 	/// Follows the string of merges starting from the given
341 	/// head commit, up till the merge with the given branch.
342 	/// Then, reapplies all merges in order,
343 	/// except for that with the given branch.
344 	public string getUnMerge(string head, string branch)
345 	{
346 		// This could be optimized using an interactive rebase
347 
348 		auto info = getMergeInfo(head);
349 		if (info.branch == branch)
350 			return info.base;
351 
352 		return getMerge(getUnMerge(info.base, branch), info.branch);
353 	}
354 
355 	// Branches, forks and customization
356 
357 	/// Return SHA1 of the given remote ref.
358 	/// Fetches the remote first, unless offline mode is on.
359 	string getRemoteRef(string remote, string remoteRef, string localRef)
360 	{
361 		needRepo();
362 		if (!offline)
363 		{
364 			log("Fetching from %s (%s -> %s) ...".format(remote, remoteRef, localRef));
365 			git.run("fetch", remote, "+%s:%s".format(remoteRef, localRef));
366 		}
367 		return getRef(localRef);
368 	}
369 
370 	/// Return SHA1 of the given pull request #.
371 	/// Fetches the pull request first, unless offline mode is on.
372 	string getPull(int pull)
373 	{
374 		return getRemoteRef(
375 			"origin",
376 			"refs/pull/%d/head".format(pull),
377 			"refs/digger/pull/%d".format(pull),
378 		);
379 	}
380 
381 	/// Return SHA1 of the given GitHub fork.
382 	/// Fetches the fork first, unless offline mode is on.
383 	/// (This is a thin wrapper around getRemoteBranch.)
384 	string getFork(string user, string branch)
385 	{
386 		enforce(user  .match(re!`^\w[\w\-]*$`), "Bad remote name");
387 		enforce(branch.match(re!`^\w[\w\-\.]*$`), "Bad branch name");
388 
389 		return getRemoteRef(
390 			"https://github.com/%s/%s".format(user, name),
391 			"refs/heads/%s".format(branch),
392 			"refs/digger/fork/%s/%s".format(user, branch),
393 		);
394 	}
395 
396 	/// Find the child of a commit, and, if the commit was a merge,
397 	/// the mainline index of said commit for the child.
398 	void getChild(string branch, string commit, out string child, out int mainline)
399 	{
400 		needCommit(branch);
401 
402 		log("Querying history for commit children...");
403 		auto history = git.getHistory([branch]);
404 
405 		bool[Hash] seen;
406 		void visit(Commit* commit)
407 		{
408 			if (commit.hash !in seen)
409 			{
410 				seen[commit.hash] = true;
411 				foreach (parent; commit.parents)
412 					visit(parent);
413 			}
414 		}
415 		auto branchHash = branch.toCommitHash();
416 		auto pBranchCommit = branchHash in history.commits;
417 		enforce(pBranchCommit, "Can't find commit " ~ branch ~" in history");
418 		visit(*pBranchCommit);
419 
420 		auto commitHash = commit.toCommitHash();
421 		auto pCommit = commitHash in history.commits;
422 		enforce(pCommit, "Can't find commit in history");
423 		auto children = (*pCommit).children;
424 		enforce(children.length, "Commit has no children");
425 		children = children.filter!(child => child.hash in seen).array();
426 		enforce(children.length, "Commit has no children under specified branch");
427 		enforce(children.length == 1, "Commit has more than one child");
428 		auto childCommit = children[0];
429 		child = childCommit.hash.toString();
430 
431 		if (childCommit.parents.length == 1)
432 			mainline = 0;
433 		else
434 		{
435 			enforce(childCommit.parents.length == 2, "Can't get mainline of multiple-branch merges");
436 			if (childCommit.parents[0] is *pCommit)
437 				mainline = 2;
438 			else
439 				mainline = 1;
440 
441 			auto mergeInfo = MergeInfo(
442 				childCommit.parents[0].hash.toString(),
443 				childCommit.parents[1].hash.toString(),
444 				true, mainline, commit);
445 			if (!mergeCache.canFind(mergeInfo))
446 			{
447 				mergeCache ~= mergeInfo;
448 				saveMergeCache();
449 			}
450 		}
451 	}
452 
453 	// State saving and checking
454 
455 	struct FileState
456 	{
457 		bool isLink;
458 		ulong size;
459 		StdTime modificationTime;
460 	}
461 
462 	FileState getFileState(string file)
463 	{
464 		assert(verify);
465 		auto path = git.path.buildPath(file);
466 		auto de = DirEntry(path);
467 		return FileState(de.isSymlink, de.size, de.timeLastModified.stdTime);
468 	}
469 
470 	alias RepositoryState = FileState[string];
471 
472 	/// Return the working tree "state".
473 	/// This returns a file list, along with size and modification time.
474 	RepositoryState getState()
475 	{
476 		assert(verify);
477 		needRepo();
478 		auto files = git.query(["ls-files"]).splitLines();
479 		RepositoryState state;
480 		foreach (file; files)
481 			state[file] = getFileState(file);
482 		return state;
483 	}
484 
485 	private @property string workTreeStatePath()
486 	{
487 		assert(verify);
488 		needRepo();
489 		return buildPath(git.gitDir, "ae-sys-d-worktree.json");
490 	}
491 
492 	/// Save the state of the working tree for versioned files
493 	/// to a .json file, which can later be verified with checkState.
494 	/// This should be called after any git command which mutates the git state.
495 	void saveState()
496 	{
497 		if (!verify)
498 			return;
499 		std.file.write(workTreeStatePath, getState().toJson());
500 	}
501 
502 	/// Save the state of just one file.
503 	/// This should be called after automatic edits to repository files during a build.
504 	/// The file parameter should be relative to the directory root, and use forward slashes.
505 	void saveFileState(string file)
506 	{
507 		if (!verify)
508 			return;
509 		if (!workTreeStatePath.exists)
510 			return;
511 		auto state = workTreeStatePath.readText.jsonParse!RepositoryState();
512 		state[file] = getFileState(file);
513 		std.file.write(workTreeStatePath, state.toJson());
514 	}
515 
516 	/// Verify that the state of the working tree matches the one
517 	/// when saveState was last called. Throw an exception otherwise.
518 	/// This and clearState should be called before any git command
519 	/// which destroys working directory changes.
520 	void checkState()
521 	{
522 		if (!verify)
523 			return;
524 		if (!workTreeStatePath.exists)
525 			return;
526 		auto savedState = workTreeStatePath.readText.jsonParse!RepositoryState();
527 		auto currentState = getState();
528 		try
529 		{
530 			foreach (file, fileState; currentState)
531 			{
532 				enforce(file in savedState, "New file: " ~ file);
533 				enforce(savedState[file].isLink == fileState.isLink,
534 					"File modified: %s (is link changed, before: %s, after: %s)".format(file, savedState[file].isLink, fileState.isLink));
535 				if (fileState.isLink)
536 					continue; // Correct lstat is too hard, just skip symlinks
537 				enforce(savedState[file].size == fileState.size,
538 					"File modified: %s (size changed, before: %s, after: %s)".format(file, savedState[file].size, fileState.size));
539 				enforce(savedState[file].modificationTime == fileState.modificationTime,
540 					"File modified: %s (modification time changed, before: %s, after: %s)".format(file, SysTime(savedState[file].modificationTime), SysTime(fileState.modificationTime)));
541 				assert(savedState[file] == fileState);
542 			}
543 		}
544 		catch (Exception e)
545 			throw new Exception(
546 				"The worktree has changed since the last time this software updated it.\n" ~
547 				"Specifically:\n" ~
548 				"    " ~ e.msg ~ "\n\n" ~
549 				"Aborting to avoid overwriting your changes.\n" ~
550 				"To continue:\n" ~
551 				" 1. Commit / stash / back up your changes, if you wish to keep them\n" ~
552 				" 2. Delete " ~ workTreeStatePath ~ "\n" ~
553 				" 3. Try this operation again."
554 			);
555 	}
556 
557 	/// Delete the saved working tree state, if any.
558 	void clearState()
559 	{
560 		if (!verify)
561 			return;
562 		if (workTreeStatePath.exists)
563 			workTreeStatePath.remove();
564 	}
565 
566 	// Misc
567 
568 	/// Reset internal state.
569 	protected void reset()
570 	{
571 		currentHead = null;
572 		clean = false;
573 		haveMergeCache = false;
574 		mergeCacheData = null;
575 	}
576 
577 	/// Override to add logging.
578 	protected abstract void log(string line);
579 }
580 
581 /// Used to communicate that a "reset --hard" failed.
582 /// Generally this indicates git repository corruption.
583 mixin DeclareException!q{RepositoryCleanException};