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