1 /**
2  * Wrappers for the git command-line tools.
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 <ae@cy.md>
12  */
13 
14 module ae.sys.git;
15 
16 import core.stdc.time : time_t;
17 
18 import std.algorithm;
19 import std.array;
20 import std.conv;
21 import std.exception;
22 import std.file;
23 import std.format;
24 import std.path;
25 import std.process;
26 import std.string;
27 import std.typecons : RefCounted;
28 import std.utf;
29 
30 import ae.sys.cmd;
31 import ae.sys.file;
32 import ae.utils.aa;
33 import ae.utils.array;
34 import ae.utils.meta;
35 import ae.utils.text;
36 
37 /// Represents an object which allows manipulating a Git repository.
38 struct Git
39 {
40 	/// Create an object which allows manipulating a Git repository.
41 	/// Because the location of $GIT_DIR (the .git directory) is queried at construction,
42 	/// the repository must exist.
43 	this(string path)
44 	{
45 		path = path.absolutePath();
46 		enforce(path.exists, "Repository path does not exist: " ~ path);
47 		gitDir = path.buildPath(".git");
48 		if (gitDir.exists && gitDir.isFile)
49 			gitDir = path.buildNormalizedPath(gitDir.readText().strip()[8..$]);
50 		//path = path.replace(`\`, `/`);
51 		this.path = path;
52 		this.commandPrefix = ["git",
53 			"-c", "core.autocrlf=false",
54 			"-c", "gc.autoDetach=false",
55 			"-C", path
56 		] ~ globalOptions;
57 		version (Windows) {} else
58 			this.environment["GIT_CONFIG_NOSYSTEM"] = "1";
59 		this.environment["HOME"] = gitDir;
60 		this.environment["XDG_CONFIG_HOME"] = gitDir;
61 	}
62 
63 	/// The path to the repository's work tree.
64 	string path;
65 
66 	/// The path to the repository's .git directory.
67 	string gitDir;
68 
69 	/// The environment that commands will be executed with.
70 	/// This field and `commandPrefix` are populated at construction,
71 	/// but may be modified afterwards, before any operations.
72 	string[string] environment;
73 
74 	/// The prefix to apply to all executed commands.
75 	/// Includes the "git" program, and all of its top-level options.
76 	string[] commandPrefix;
77 
78 	/// Global options to add to `commandPrefix` during construction.
79 	static string[] globalOptions; // per-thread
80 
81 	invariant()
82 	{
83 		assert(environment !is null, "Not initialized");
84 	}
85 
86 	// Have just some primitives here.
87 	// Higher-level functionality can be added using UFCS.
88 
89 	/// Run a command. Throw if it fails.
90 	void   run  (string[] args...) const { return .run  (commandPrefix ~ args, environment, path); }
91 	/// Run a command, and return its output, sans trailing newline.
92 	string query(string[] args...) const { return .query(commandPrefix ~ args, environment, path).chomp(); }
93 	/// Run a command, and return true if it succeeds.
94 	bool   check(string[] args...) const { return spawnProcess(commandPrefix ~ args, environment, Config.none, path).wait() == 0; }
95 	/// Run a command with pipe redirections. Return the pipes.
96 	auto   pipe (string[] args, Redirect redirect)
97 	                               const { return pipeProcess(commandPrefix ~ args, redirect, environment, Config.none, path); }
98 	auto   pipe (string[] args...) const { return pipe(args, Redirect.stdin | Redirect.stdout); } /// ditto
99 
100 	/// A parsed Git author/committer line.
101 	struct Authorship
102 	{
103 		/// Format string which can be used with
104 		/// ae.utils.time to parse or format Git dates.
105 		enum dateFormat = "U O";
106 
107 		string name; /// Name (without email).
108 		string email; /// Email address (without the < / > delimiters).
109 		string date; /// Raw date. Use `dateFormat` with ae.utils.time to parse.
110 
111 		/// Parse from a raw author/committer line.
112 		this(string authorship)
113 		{
114 			auto parts1 = authorship.findSplit(" <");
115 			auto parts2 = parts1[2].findSplit("> ");
116 			this.name = parts1[0];
117 			this.email = parts2[0];
118 			this.date = parts2[2];
119 		}
120 
121 		/// Construct from fields.
122 		this(string name, string email, string date)
123 		{
124 			this.name = name;
125 			this.email = email;
126 			this.date = date;
127 		}
128 
129 		/// Convert to a raw author/committer line.
130 		string toString() const { return name ~ " <" ~ email ~ "> " ~ date; }
131 	}
132 
133 	/// A convenience function which loads the entire Git history into a graph.
134 	struct History
135 	{
136 		/// An entry corresponding to a Git commit in this `History` object.
137 		struct Commit
138 		{
139 			size_t index; /// Index in this `History` instance.
140 			CommitID oid;  /// The commit hash.
141 			time_t time;  /// UNIX time.
142 			string author;  /// Raw author/committer lines. Use Authorship to parse.
143 			string committer;  /// ditto
144 			string[] message; /// Commit message lines.
145 			Commit*[] parents; /// Edges to neighboring commits. Children order is unspecified.
146 			Commit*[] children; /// ditto
147 
148 			deprecated alias hash = oid;
149 			deprecated alias id = index;
150 
151 			/// Get or set author/committer lines as parsed object.
152 			@property Authorship parsedAuthor() { return Authorship(author); }
153 			@property Authorship parsedCommitter() { return Authorship(committer); } /// ditto
154 			@property void parsedAuthor(Authorship authorship) { author = authorship.toString(); } /// ditto
155 			@property void parsedCommitter(Authorship authorship) { committer = authorship.toString(); } /// ditto
156 		}
157 
158 		Commit*[CommitID] commits; /// All commits in this `History` object.
159 		size_t numCommits = 0; /// Number of commits in `commits`.
160 		CommitID[string] refs; /// A map of full Git refs (e.g. "refs/heads/master") to their commit IDs.
161 	}
162 
163 	/// ditto
164 	History getHistory(string[] extraArgs = null) const
165 	{
166 		History history;
167 
168 		History.Commit* getCommit(CommitID oid)
169 		{
170 			auto pcommit = oid in history.commits;
171 			return pcommit ? *pcommit : (history.commits[oid] = new History.Commit(history.numCommits++, oid));
172 		}
173 
174 		History.Commit* commit;
175 		string currentBlock;
176 
177 		foreach (line; query([`log`, `--all`, `--pretty=raw`] ~ extraArgs).split('\n'))
178 		{
179 			if (!line.length)
180 			{
181 				if (currentBlock)
182 					currentBlock = null;
183 				continue;
184 			}
185 
186 			if (currentBlock)
187 			{
188 				enforce(line.startsWith(" "), "Expected " ~ currentBlock ~ " line in git log");
189 				continue;
190 			}
191 
192 			if (line.startsWith("commit "))
193 			{
194 				auto hash = CommitID(line[7..$]);
195 				commit = getCommit(hash);
196 			}
197 			else
198 			if (line.startsWith("tree "))
199 				continue;
200 			else
201 			if (line.startsWith("parent "))
202 			{
203 				auto hash = CommitID(line[7..$]);
204 				auto parent = getCommit(hash);
205 				commit.parents ~= parent;
206 				parent.children ~= commit;
207 			}
208 			else
209 			if (line.startsWith("author "))
210 				commit.author = line[7..$];
211 			else
212 			if (line.startsWith("committer "))
213 			{
214 				commit.committer = line[10..$];
215 				commit.time = line.split(" ")[$-2].to!int();
216 			}
217 			else
218 			if (line.startsWith("    "))
219 				commit.message ~= line[4..$];
220 			else
221 			if (line.startsWith("gpgsig "))
222 				currentBlock = "GPG signature";
223 			else
224 			if (line.startsWith("mergetag "))
225 				currentBlock = "Tag merge";
226 			else
227 				enforce(false, "Unknown line in git log: " ~ line);
228 		}
229 
230 		if (!history.commits)
231 			return history; // show-ref will fail if there are no refs
232 
233 		foreach (line; query([`show-ref`, `--dereference`]).splitLines())
234 		{
235 			auto h = CommitID(line[0..40]);
236 			enforce(h in history.commits, "Ref commit not in log: " ~ line);
237 			history.refs[line[41..$]] = h;
238 		}
239 
240 		return history;
241 	}
242 
243 	// Low-level pipes
244 
245 	/// Git object identifier (identifies blobs, trees, commits, etc.)
246 	struct OID
247 	{
248 		/// Watch me: new hash algorithms may be supported in the future.
249 		ubyte[20] sha1;
250 
251 		deprecated alias sha1 this;
252 
253 		/// Construct from an ASCII string.
254 		this(in char[] sha1)
255 		{
256 			enforce(sha1.length == 40, "Bad SHA-1 length: " ~ sha1);
257 			foreach (i, ref b; this.sha1)
258 				b = to!ubyte(sha1[i*2..i*2+2], 16);
259 		}
260 
261 		/// Convert to the ASCII representation.
262 		string toString() pure const
263 		{
264 			char[40] buf = sha1.toLowerHex();
265 			return buf[].idup;
266 		}
267 
268 		debug(ae_unittest) unittest
269 		{
270 			OID oid;
271 			oid.sha1 = [0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF, 0x01, 0x23, 0x45, 0x67];
272 			assert(oid.toString() == "0123456789abcdef0123456789abcdef01234567");
273 		}
274 	}
275 
276 	private mixin template TypedObjectID(string type_)
277 	{
278 		/// As in git-hash-object's -t parameter.
279 		enum type = type_;
280 
281 		OID oid; /// The generic object identifier.
282 		alias oid this;
283 
284 		this(OID oid) { this.oid = oid; } /// Construct from a generic identifier.
285 		this(in char[] hash) { oid = OID(hash); } /// Construct from an ASCII string.
286 		string toString() pure const { return oid.toString(); } /// Convert to the ASCII representation.
287 
288 		// Disable implicit conversion directly between different kinds of OIDs.
289 		static if (!is(typeof(this) == CommitID)) @disable this(CommitID);
290 		static if (!is(typeof(this) == TreeID)) @disable this(TreeID);
291 		static if (!is(typeof(this) == BlobID)) @disable this(BlobID);
292 	}
293 	/// Strong typed OIDs to distinguish which kind of object they identify.
294 	struct CommitID { mixin TypedObjectID!"commit"; }
295 	struct TreeID   { mixin TypedObjectID!"tree"  ; } /// ditto
296 	struct BlobID   { mixin TypedObjectID!"blob"  ; } /// ditto
297 
298 	/// The parsed representation of a raw Git object.
299 	struct Object
300 	{
301 		/// Object identifier. Will be OID.init initially.
302 		OID oid;
303 
304 		/// Object type, as in git-hash-object's -t parameter.
305 		string type;
306 
307 		/// The raw data contained in this object.
308 		immutable(ubyte)[] data;
309 
310 		deprecated alias hash = oid;
311 
312 		/// Create a blob object (i.e., a file) from raw bytes.
313 		static Object createBlob(immutable(ubyte)[] data)
314 		{
315 			return Object(OID.init, "blob", data);
316 		}
317 
318 		/// "Parse" this raw Git object as a blob.
319 		immutable(ubyte)[] parseBlob()
320 		{
321 			enforce(type == "blob", "Wrong object type");
322 			return data;
323 		}
324 
325 		/// Represents a parsed Git commit object.
326 		struct ParsedCommit
327 		{
328 			/// The Git OID of this commit's tree.
329 			TreeID tree;
330 
331 			/// This commit's parents.
332 			CommitID[] parents;
333 
334 			/// Raw author/committer lines.
335 			string author, committer;
336 
337 			/// Commit message lines.
338 			string[] message;
339 
340 			/// GPG signature certifying this commit, if any.
341 			string[][] gpgsig;
342 
343 			/// "mergetag" signatures (on commits that merge a tag), if any.
344 			string[][] mergetag;
345 
346 			/// Get or set author/committer lines as parsed object.
347 			@property Authorship parsedAuthor() { return Authorship(author); }
348 			@property Authorship parsedCommitter() { return Authorship(committer); } /// ditto
349 			@property void parsedAuthor(Authorship authorship) { author = authorship.toString(); } /// ditto
350 			@property void parsedCommitter(Authorship authorship) { committer = authorship.toString(); } /// ditto
351 		}
352 
353 		/// Parse this raw Git object as a commit.
354 		ParsedCommit parseCommit()
355 		{
356 			enforce(type == "commit", "Wrong object type");
357 			ParsedCommit result;
358 			auto lines = (cast(string)data).split('\n');
359 			while (lines.length)
360 			{
361 				auto line = lines.shift();
362 				if (line == "")
363 				{
364 					result.message = lines;
365 					break; // commit message begins
366 				}
367 				auto parts = line.findSplit(" ");
368 				auto field = parts[0];
369 				line = parts[2];
370 				switch (field)
371 				{
372 					case "tree":
373 						result.tree = TreeID(line);
374 						break;
375 					case "parent":
376 						result.parents ~= CommitID(line);
377 						break;
378 					case "author":
379 						result.author = line;
380 						break;
381 					case "committer":
382 						result.committer = line;
383 						break;
384 					case "gpgsig":
385 					{
386 						auto p = lines.countUntil!(line => !line.startsWith(" "));
387 						if (p < 0)
388 							p = lines.length;
389 						result.gpgsig ~= [line] ~ lines[0 .. p].apply!(each!((ref line) => line.skipOver(" ").enforce("gpgsig line without leading space")));
390 						lines = lines[p .. $];
391 						break;
392 					}
393 					case "mergetag":
394 					{
395 						auto p = lines.countUntil!(line => !line.startsWith(" "));
396 						if (p < 0)
397 							p = lines.length;
398 						result.mergetag ~= [line] ~ lines[0 .. p].apply!(each!((ref line) => line.skipOver(" ").enforce("mergetag line without leading space")));
399 						lines = lines[p .. $];
400 						break;
401 					}
402 					default:
403 						throw new Exception("Unknown commit field: " ~ field ~ "\n" ~ cast(string)data);
404 				}
405 			}
406 			return result;
407 		}
408 
409 		/// Format a Git commit into a raw Git object.
410 		static Object createCommit(in ParsedCommit commit)
411 		{
412 			auto s = "tree %s\n%-(parent %s\n%|%)author %s\ncommitter %s\n\n%-(%s\n%)".format(
413 					commit.tree.toString(),
414 					commit.parents.map!((ref const CommitID oid) => oid.toString()),
415 					commit.author,
416 					commit.committer,
417 					commit.message,
418 				);
419 			return Object(OID.init, "commit", cast(immutable(ubyte)[])s);
420 		}
421 
422 		/// Represents an entry in a parsed Git commit object.
423 		struct TreeEntry
424 		{
425 			uint mode;     /// POSIX mode. E.g., will be 100644 or 100755 for files.
426 			string name;   /// Name within this subtree.
427 			OID hash;      /// Object identifier of the entry's contents. Could be a tree or blob ID.
428 
429 			/// Sort key to be used when constructing a tree object.
430 			@property string sortName() const { return (mode & octal!40000) ? name ~ "/" : name; }
431 
432 			/// Implements comparison using `sortName`.
433 			int opCmp(ref const TreeEntry b) const
434 			{
435 				return cmp(sortName, b.sortName);
436 			}
437 		}
438 
439 		/// Parse this raw Git object as a tree.
440 		TreeEntry[] parseTree()
441 		{
442 			enforce(type == "tree", "Wrong object type");
443 			TreeEntry[] result;
444 			auto rem = data;
445 			while (rem.length)
446 			{
447 				auto si = rem.countUntil(' ');
448 				auto zi = rem.countUntil(0);
449 				auto ei = zi + 1 + OID.sha1.length;
450 				auto str = cast(string)rem[0..zi];
451 				enforce(0 < si && si < zi && ei <= rem.length, "Malformed tree entry:\n" ~ hexDump(rem));
452 				OID oid;
453 				oid.sha1 = rem[zi+1..ei][0..OID.sha1.length];
454 				result ~= TreeEntry(str[0..si].to!uint(8), str[si+1..zi], oid); // https://issues.dlang.org/show_bug.cgi?id=13112
455 				rem = rem[ei..$];
456 			}
457 			return result;
458 		}
459 
460 		/// Format a Git tree into a raw Git object.
461 		/// Tree entries must be sorted (TreeEntry implements an appropriate opCmp).
462 		static Object createTree(in TreeEntry[] entries)
463 		{
464 			auto buf = appender!(immutable(ubyte)[]);
465 			foreach (entry; entries)
466 			{
467 				buf.formattedWrite("%o %s\0", entry.mode, entry.name);
468 				buf.put(entry.hash[]);
469 			}
470 			return Object(OID.init, "tree", buf.data);
471 		}
472 	}
473 
474 	/// Spawn a cat-file process which can read git objects by demand.
475 	struct ObjectReaderImpl
476 	{
477 		private ProcessPipes pipes;
478 
479 		/// Read an object by its identifier.
480 		Object read(string name)
481 		{
482 			pipes.stdin.writeln(name);
483 			pipes.stdin.flush();
484 
485 			auto headerLine = pipes.stdout.safeReadln().strip();
486 			auto header = headerLine.split(" ");
487 			enforce(header.length == 3, "Malformed header during cat-file: " ~ headerLine);
488 			auto oid = OID(header[0]);
489 
490 			Object obj;
491 			obj.oid = oid;
492 			obj.type = header[1];
493 			auto size = to!size_t(header[2]);
494 			if (size)
495 			{
496 				auto data = new ubyte[size];
497 				auto read = pipes.stdout.rawRead(data);
498 				enforce(read.length == size, "Unexpected EOF during cat-file");
499 				obj.data = data.assumeUnique();
500 			}
501 
502 			char[1] lf;
503 			pipes.stdout.rawRead(lf[]);
504 			enforce(lf[0] == '\n', "Terminating newline expected");
505 
506 			return obj;
507 		}
508 
509 		/// ditto
510 		Object read(OID oid)
511 		{
512 			auto obj = read(oid.toString());
513 			enforce(obj.oid == oid, "Unexpected object during cat-file");
514 			return obj;
515 		}
516 
517 		~this()
518 		{
519 			pipes.stdin.close();
520 			enforce(pipes.pid.wait() == 0, "git cat-file exited with failure");
521 		}
522 	}
523 	alias ObjectReader = RefCounted!ObjectReaderImpl; /// ditto
524 
525 	/// ditto
526 	ObjectReader createObjectReader()
527 	{
528 		auto pipes = this.pipe(`cat-file`, `--batch`);
529 		return ObjectReader(pipes);
530 	}
531 
532 	/// Run a batch cat-file query.
533 	Object[] getObjects(OID[] hashes)
534 	{
535 		Object[] result;
536 		result.reserve(hashes.length);
537 		auto reader = createObjectReader();
538 
539 		foreach (hash; hashes)
540 			result ~= reader.read(hash);
541 
542 		return result;
543 	}
544 
545 	/// Spawn a hash-object process which can hash and write git objects on the fly.
546 	struct ObjectWriterImpl
547 	{
548 		private bool initialized;
549 		private ProcessPipes pipes;
550 
551 		/*private*/ this(ProcessPipes pipes)
552 		{
553 			this.pipes = pipes;
554 			initialized = true;
555 		}
556 
557 		/// Write a raw Git object of this writer's type, and return the OID.
558 		OID write(in ubyte[] data)
559 		{
560 			import std.random : uniform;
561 			auto p = NamedPipe("ae-sys-git-writeObjects-%d".format(uniform!ulong));
562 			pipes.stdin.writeln(p.fileName);
563 			pipes.stdin.flush();
564 
565 			auto f = p.connect();
566 			f.rawWrite(data);
567 			f.flush();
568 			f.close();
569 
570 			return OID(pipes.stdout.safeReadln().strip());
571 		}
572 
573 		deprecated OID write(const(void)[] data) { return write(cast(const(ubyte)[]) data); }
574 
575 		~this()
576 		{
577 			if (initialized)
578 			{
579 				pipes.stdin.close();
580 				enforce(pipes.pid.wait() == 0, "git hash-object exited with failure");
581 				initialized = false;
582 			}
583 		}
584 	}
585 	alias ObjectWriter = RefCounted!ObjectWriterImpl; /// ditto
586 
587 	ObjectWriter createObjectWriter(string type)
588 	{
589 		auto pipes = this.pipe(`hash-object`, `-t`, type, `-w`, `--stdin-paths`);
590 		return ObjectWriter(pipes);
591 	} /// ditto
592 
593 	struct ObjectMultiWriterImpl
594 	{
595 		private Git repo;
596 
597 		/// The ObjectWriter instances for each individual type.
598 		ObjectWriter treeWriter, blobWriter, commitWriter;
599 
600 		/// Write a Git object, and return the OID.
601 		OID write(in Object obj)
602 		{
603 			ObjectWriter* pwriter;
604 			switch (obj.type) // https://issues.dlang.org/show_bug.cgi?id=14595
605 			{
606 				case "tree"  : pwriter = &treeWriter  ; break;
607 				case "blob"  : pwriter = &blobWriter  ; break;
608 				case "commit": pwriter = &commitWriter; break;
609 				default: throw new Exception("Unknown object type: " ~ obj.type);
610 			}
611 			if (!pwriter.initialized)
612 				*pwriter = ObjectWriter(repo.pipe(`hash-object`, `-t`, obj.type, `-w`, `--stdin-paths`));
613 			return pwriter.write(obj.data);
614 		}
615 
616 		/// Format and write a Git object, and return the OID.
617 		CommitID write(in Object.ParsedCommit commit) { return CommitID(write(Object.createCommit(commit))); }
618 		TreeID   write(in Object.TreeEntry[] entries) { return TreeID  (write(Object.createTree(entries))); } /// ditto
619 		BlobID   write(immutable(ubyte)[] bytes     ) { return BlobID  (write(Object.createBlob(bytes))); } /// ditto
620 	} /// ditto
621 	alias ObjectMultiWriter = RefCounted!ObjectMultiWriterImpl; /// ditto
622 
623 	/// ditto
624 	ObjectMultiWriter createObjectWriter()
625 	{
626 		return ObjectMultiWriter(this);
627 	}
628 
629 	/// Batch-write the given objects to the database.
630 	/// The hashes are saved to the "hash" fields of the passed objects.
631 	void writeObjects(Git.Object[] objects)
632 	{
633 		string[] allTypes = objects.map!(obj => obj.type).toSet().keys;
634 		foreach (type; allTypes)
635 		{
636 			auto writer = createObjectWriter(type);
637 			foreach (ref obj; objects)
638 				if (obj.type == type)
639 					obj.oid = writer.write(obj.data);
640 		}
641 	}
642 
643 	/// Extract a commit's tree to a given directory
644 	void exportCommit(string commit, string path, ObjectReader reader, bool delegate(string) pathFilter = null)
645 	{
646 		exportTree(reader.read(commit).parseCommit().tree, path, reader, pathFilter);
647 	}
648 
649 	/// Extract a tree to a given directory
650 	void exportTree(TreeID treeHash, string path, ObjectReader reader, bool delegate(string) pathFilter = null)
651 	{
652 		void exportSubTree(OID treeHash, string[] subPath)
653 		{
654 			auto tree = reader.read(treeHash).parseTree();
655 			foreach (entry; tree)
656 			{
657 				auto entrySubPath = subPath ~ entry.name;
658 				if (pathFilter && !pathFilter(entrySubPath.join("/")))
659 					continue;
660 				auto entryPath = buildPath([path] ~ entrySubPath);
661 				switch (entry.mode)
662 				{
663 					case octal!100644: // file
664 					case octal!100755: // executable file
665 						std.file.write(entryPath, reader.read(entry.hash).data);
666 						version (Posix)
667 						{
668 							// Make executable
669 							if (entry.mode == octal!100755)
670 								entryPath.setAttributes(entryPath.getAttributes | ((entryPath.getAttributes & octal!444) >> 2));
671 						}
672 						break;
673 					case octal! 40000: // tree
674 						mkdirRecurse(entryPath);
675 						exportSubTree(entry.hash, entrySubPath);
676 						break;
677 					case octal!160000: // submodule
678 						mkdirRecurse(entryPath);
679 						break;
680 					default:
681 						throw new Exception("Unknown git file mode: %o".format(entry.mode));
682 				}
683 			}
684 		}
685 		exportSubTree(treeHash, null);
686 	}
687 
688 	/// Import a directory tree into the object store, and return the new tree object's hash.
689 	TreeID importTree(string path, ObjectMultiWriter writer, bool delegate(string) pathFilter = null)
690 	{
691 		static // Error: variable ae.sys.git.Repository.importTree.writer has scoped destruction, cannot build closure
692 		TreeID importSubTree(string path, string subPath, ref ObjectMultiWriter writer, bool delegate(string) pathFilter)
693 		{
694 			auto entries = subPath
695 				.dirEntries(SpanMode.shallow)
696 				.filter!(de => !pathFilter || pathFilter(de.relativePath(path)))
697 				.map!(de =>
698 					de.isDir
699 					? Object.TreeEntry(
700 						octal!40000,
701 						de.baseName,
702 						importSubTree(path, buildPath(subPath, de.baseName), writer, pathFilter)
703 					)
704 					: Object.TreeEntry(
705 						isVersion!`Posix` && (de.attributes & octal!111) ? octal!100755 : octal!100644,
706 						de.baseName,
707 						writer.write(Git.Object(OID.init, "blob", cast(immutable(ubyte)[])read(de.name)))
708 					)
709 				)
710 				.array
711 				.sort!((a, b) => a.sortName < b.sortName).release
712 			;
713 			return TreeID(writer.write(Object.createTree(entries)));
714 		}
715 		return importSubTree(path, path, writer, pathFilter);
716 	}
717 
718 	/// Spawn a update-ref process which can update git refs on the fly.
719 	struct RefWriterImpl
720 	{
721 		private bool initialized;
722 		private ProcessPipes pipes;
723 
724 		/*private*/ this(ProcessPipes pipes)
725 		{
726 			this.pipes = pipes;
727 			initialized = true;
728 		}
729 
730 		private void op(string op)
731 		{
732 			pipes.stdin.write(op, '\0');
733 			pipes.stdin.flush();
734 		}
735 
736 		private void op(string op, bool noDeref, string refName, CommitID*[] hashes...)
737 		{
738 			if (noDeref)
739 				pipes.stdin.write("option no-deref\0");
740 			pipes.stdin.write(op, " ", refName, '\0');
741 			foreach (hash; hashes)
742 			{
743 				if (hash)
744 					pipes.stdin.write((*hash).toString());
745 				pipes.stdin.write('\0');
746 			}
747 			pipes.stdin.flush();
748 		}
749 
750 		/// Send update-ref operations (as specified in its man page).
751 		void update   (string refName, CommitID newValue                   , bool noDeref = false) { op("update", noDeref, refName, &newValue, null     ); }
752 		void update   (string refName, CommitID newValue, CommitID oldValue, bool noDeref = false) { op("update", noDeref, refName, &newValue, &oldValue); } /// ditto
753 		void create   (string refName, CommitID newValue                   , bool noDeref = false) { op("create", noDeref, refName, &newValue           ); } /// ditto
754 		void deleteRef(string refName                                      , bool noDeref = false) { op("delete", noDeref, refName,            null     ); } /// ditto
755 		void deleteRef(string refName,                    CommitID oldValue, bool noDeref = false) { op("delete", noDeref, refName,            &oldValue); } /// ditto
756 		void verify   (string refName                                      , bool noDeref = false) { op("verify", noDeref, refName,            null     ); } /// ditto
757 		void verify   (string refName,                    CommitID oldValue, bool noDeref = false) { op("verify", noDeref, refName,            &oldValue); } /// ditto
758 		void start    (                                                                          ) { op("start"                                         ); } /// ditto
759 		void prepare  (                                                                          ) { op("prepare"                                       ); } /// ditto
760 		void commit   (                                                                          ) { op("commit"                                        ); } /// ditto
761 		void abort    (                                                                          ) { op("abort"                                         ); } /// ditto
762 
763 		deprecated void update   (string refName, OID newValue              , bool noDeref = false) { op("update", noDeref, refName, cast(CommitID*)&newValue, null                    ); }
764 		deprecated void update   (string refName, OID newValue, OID oldValue, bool noDeref = false) { op("update", noDeref, refName, cast(CommitID*)&newValue, cast(CommitID*)&oldValue); }
765 		deprecated void create   (string refName, OID newValue              , bool noDeref = false) { op("create", noDeref, refName, cast(CommitID*)&newValue                          ); }
766 		deprecated void deleteRef(string refName,               OID oldValue, bool noDeref = false) { op("delete", noDeref, refName,                           cast(CommitID*)&oldValue); }
767 		deprecated void verify   (string refName,               OID oldValue, bool noDeref = false) { op("verify", noDeref, refName,                           cast(CommitID*)&oldValue); }
768 
769 		~this()
770 		{
771 			if (initialized)
772 			{
773 				pipes.stdin.close();
774 				enforce(pipes.pid.wait() == 0, "git update-ref exited with failure");
775 				initialized = false;
776 			}
777 		}
778 	}
779 	alias RefWriter = RefCounted!RefWriterImpl; /// ditto
780 
781 	/// ditto
782 	RefWriter createRefWriter()
783 	{
784 		auto pipes = this.pipe(`update-ref`, `-z`, `--stdin`);
785 		return RefWriter(pipes);
786 	}
787 
788 	/// Tries to match the default destination of `git clone`.
789 	static string repositoryNameFromURL(string url)
790 	{
791 		return url
792 			.split(":")[$-1]
793 			.split("/")[$-1]
794 			.chomp(".git");
795 	}
796 
797 	debug(ae_unittest) unittest
798 	{
799 		assert(repositoryNameFromURL("https://github.com/CyberShadow/ae.git") == "ae");
800 		assert(repositoryNameFromURL("git@example.com:ae.git") == "ae");
801 	}
802 }
803 
804 deprecated alias Repository = Git;
805 deprecated alias History = Git.History;
806 deprecated alias Commit = Git.History.Commit;
807 deprecated alias GitObject = Git.Object;
808 deprecated alias Hash = Git.OID;
809 deprecated Git.Authorship parseAuthorship(string authorship) { return Git.Authorship(authorship); }
810 
811 deprecated Git.CommitID toCommitHash(in char[] hash) { return Git.CommitID(Git.OID(hash)); }
812 
813 debug(ae_unittest) deprecated unittest
814 {
815 	assert(toCommitHash("0123456789abcdef0123456789ABCDEF01234567").oid.sha1 == [0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF, 0x01, 0x23, 0x45, 0x67]);
816 }
817 
818 deprecated string toString(ref const Git.OID oid) { return oid.toString(); }
819 
820 deprecated alias repositoryNameFromURL = Git.repositoryNameFromURL;