/* Copyright 2010 Aaron J. Radke 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. */ // vim: set ts=3 sw=3 et: package cc.drx object Git{ private lazy val gitDirFiles:List[String] = "HEAD config objects refs".split(' ').toList private def check(dir:File)(implicit ec:ExecutionContext):Option[Git] = if(gitDirFiles forall {f => (dir/f).exists}) Some(new Git(dir)) else None def find(f:File)(implicit ec:ExecutionContext):Option[Git] = check(f) || check(f/".git") || check(f companion "git") || check(f/"../.git") || check(f/"../../.git") def apply(f:File)(implicit ec:ExecutionContext):Git = find(f.canon) | new Git(f) def apply()(implicit ec:ExecutionContext):Git = Git(File.cwd) //TODO add clone capability and shallow clones /* def clone(f:File) = ??? def clone(url:URL) = ??? */ //TODO use the following classes to make reprts and logs cleaner /* case class Rev(sha1:String) */ // def main(args:Array[String]):Unit = { // } } class Git private(val gitDir:File)(implicit ec:ExecutionContext){ override def toString = "Git("+gitDir.path+")" //--type aliases private type Result[A] = Future[List[A]] // private val emptyResult = Future.successful(List.empty[String]) //TODO make this private again private val BareTruePat = """(\s*)(bare\s*=\s*true\s*)""".r.ignoreCase private val BareFalsePat = """(\s*)(bare\s*=\s*false\s*)""".r.ignoreCase //--helper vals val config = gitDir/"config" private lazy val configLines = config.in.lines.toList //bare features lazy val isBare:Boolean = configLines exists (BareTruePat matchesFully _) def syncBareTo(dst:File,dryRun:Boolean = false):Git = { Sync(gitDir, dst, dryRun) Git(dst).makeBare } def makeBare:Git = { def explicitlyNotBare = configLines exists (BareFalsePat matchesFully _) if(isBare) println(Orange("Warn, this repo is already bare `bare = true`")) else if(!explicitlyNotBare) println(Red("Error, this can only make repo's bare that explictly set `bare = false` in .git/config")) else { config.out.print{f => configLines.map{ case BareFalsePat(space,_) => space + "bare = true" case line => line } foreach f.println } } Git(gitDir) //make a new one so the config is scanned again } private val gitCmd = if(isBare) List("git","--git-dir", gitDir.path) else List("git","--git-dir", gitDir.path, "--work-tree", workTree.path) //TODO add --work-tree in order to be entirely explicit //--helper defs def git(cmd:String, args:String*):Shell = { val shell = Shell(gitCmd ++ cmd.split("\\s+") ++ args) // println(shell) //debug executed shell command shell } //--tests /**check if a git repo has modified files */ def isDirty:Future[Boolean] = if(isBare) Future.successful(false) else git("diff-index --quiet HEAD --").exitCode.map{_ != 0} //exit code for git diff-index (0 == no differences; 1 == differences // http://stackoverflow.com/a/2659808/622016 //--commands def autoCommit(msg:String="auto commit"):Result[String] = for( dirt <- isDirty; if(dirt); lines <- git("commit -a -m",msg).lines) yield lines def commit(msg:String):Result[String] = git("commit -m", msg).lines def push(args:String*):Result[String] = git("push", args:_*).lines def pull(args:String*):Result[String] = git("pull", args:_*).lines def merge(args:String*):Result[String] = git("merge", args:_*).lines def fetch(args:String*):Result[String] = { for( fs <- git("fetch", args:_*).lines) yield fs } def prune(remote:String="origin"):Result[String] = git("remote prune", remote).lines // def merge(args:String*):Result[String] = git("merge", args:_*).lines def clean:Result[File] = git("clean -fd").linesAs{line => File( line.replace("Removing ","").replace("Would remove ","")) //get the actual file name } def remotes:Result[Remote] = git("remote -v").linesAs{line => val cs = line.split("""\s+""").toList Remote(cs(0), cs(1)) } //--the base dir if a working dir otherwise the .git dir if bare //--work-tree lazy val dir:File = workTree lazy val workTree = if(isBare) gitDir else (gitDir/"..").canon //--drx-ified commands // FIXME remove private def as[A](lineF:String => A)(res:Result[String]):Result[File] = res.map{_ map lineF} // FIXME remove debug private def asFiles(res:Result[String]):Result[File] = as(File.apply) //res.map{_.map File.apply} // def branch:Try[String] = for(lines <- git("rev-parse --abbrev-ref HEAD"); head <- lines.headOption) yield head //http://stackoverflow.com/a/12142066/622016 // def branch:Future[String] = git("rev-parse --abbrev-ref HEAD") map {_.headOption | "<error>"} //http://stackoverflow.com/a/12142066/622016 def branch:Future[String] = for(lines <- git("rev-parse --abbrev-ref HEAD").lines; if lines.nonEmpty) yield lines.head //http://stackoverflow.com/a/12142066/622016 def staged:Result[File] = git("diff --name-only --cached").linesAs(File.apply) def diff:Result[File] = git("diff --name-only").linesAs(File.apply) def patch:Future[String] = git("diff").lines map {_ mkString "\n"} def show(date:Date,file:File):Result[String] = git("show", "HEAD@{"+date.isoDay+"}:"+file.path).lines // def status(args:String*):Result = git("status", args:_*) // def log(args:String*):Result = git("log", args:_*) def log(format:String):Result[String] = git("log",s"--format=$format","--decorate=full").lines //TODO maybe quotes are needed aounrd the format for the windows version /* dangerous commands but gets it back to a non-dirty state https://stackoverflow.com/a/31321921/622016 */ def resetAndClean():Result[String] = git("reset --hard").lines.flatMap{_ => git("clean -dfx").lines } //TODO pipe all results through the monad results case class Remote(name:String, url:String) //TODO make the sha1 a sha1 type rather than a string case class Ref(path:String) { lazy val short = path strip "refs/tags/ refs/heads/ refs/remotes/".split(" ").toList lazy val isTag = path startsWith "refs/tags/" lazy val isRemote = path startsWith "refs/remotes/" lazy val isLocal = path startsWith "refs/heads/" lazy val isVersion = isTag && ( short.startsWith("v") || VersionPat.matchesFully(short) ) lazy val isKrypton = isTag && KryptonPat.matchesFully(short) def nice = (if(isVersion) Red else if(isTag) Orange else if(isLocal) Green else Blue) ansi short private lazy val VersionPat = """v?[\d\.]+""".r private lazy val KryptonPat = """@[0-9a-zA-Z]+""".r } case class Commit(id:String, author:String, email:String, date:Date, subject:String, body:String, refs:List[Ref]){ lazy val msg:String = if(body.trim.nonEmpty) subject + "\n" + body else subject //TODO add link to a parent commit??? lazy val lastName = { val names = author.split("\\s+") if(author contains ",") names.headOption.getOrElse(author) //last name is listed first when a comma appears else names.lastOption.getOrElse(author) //last name is last in normal notation } lazy val shortId = id.take(10) private def niceRefs = if(refs.isEmpty) "" else refs.map{_.nice}.mkString("(",", ",")") def nice:String = f"$shortId ${lastName take 8}%8s ${date.toRelativeString}%10s ${niceRefs} ${msg truncate 80}" } 2017-07-30("use commmits instead","di4") def log:Result[Commit] = commits def head:Future[Commit] = logCommits("HEAD","-1").map{_.head} def commits:Result[Commit] = logCommits() def commits(lastN:Int):Result[Commit] = logCommits(s"-$lastN") //warning the "-100" count limit needs to happen before --no-walk val tags:Result[(Ref,Commit)] = logCommitRefs("--no-walk", "--tags") val refs:Result[(Ref,Commit)] = logCommitRefs() def versions:Result[(Version,Commit)] = tags.map{ts => ts.collect{case (ref,c) if ref.isVersion => val r = ref.short val ver = if(r.startsWith("v") || r.startsWith("@")) r.drop(1) else r Version(ver) -> c }.sortBy{_._1} }//filter tags with a tag filter on version type private def logCommitRefs(optFlags:String*):Result[(Ref,Commit)] = logCommits(optFlags:_*).map{cs => //map the future commits for(c <- cs; r <- c.refs) yield r -> c //unfold each ref (some commits may have multiple refs) } //http://blog.lost-theory.org/post/how-to-parse-git-log-output/ private def logCommits(optFlags:String*):Result[Commit] = { val fieldSep = Data.Ansi.Field val recordSep = Data.Ansi.Record //name:an email:ae date:at msg:s d:ref list val format = "H an ae at s b d".split(' ').map("%"+_).mkString(fieldSep) + recordSep val logCmd = git("log",s"--format=$format","--decorate=full") ++ optFlags //TODO maybe quotes are needed aounrd the format for the windows version val logResult = logCmd.lines logResult.map{ lines => val records = lines.mkString("").split(recordSep) for(record <- records.toList; f = record split fieldSep/*; if f.size >= 6 */) yield { val (body,refs) = { val noRefs = List.empty[Git.this.Ref] f.size match { case 5 => ("", noRefs) case 6 => (f(5), noRefs) case 7 => (f(5), { val stripValues = "HEAD ->,(,),tag:".split(',') f(6).strip(stripValues).trim .split("""\s*,\s*""").toList .map(Ref.apply) } ) } } // debug println(s"git f.size: ${f.size} ${record truncate 200}") Commit(f(0), f(1), f(2), Date.fromSec(f(3).toLong), f(4), body, refs) } } } def krypton:Future[Version] = for(h <- head; ts <- tags) yield { h.refs.find(_.isKrypton) match { //--if the current head is tagged as a krypton version use this as the version case Some(headRef) => Version(headRef.short drop 1) case None => ts.map{_._1}.filter(_.isKrypton).map{_.short}.sorted.lastOption match { //--find the latest tagged krypton version and increment the version based on the current date case Some(short) => Version.kryptonNext(Version(short drop 1), Date.now) //--the first release just uses the top level krypton date case None => Version(Date.now.krypton take 1) //no version has been defined yet } } } def undo(file:File):Result[String] = git("checkout","--",file.path).lines def sync(remote:String="origin", branch:String="master"):Result[String] = for(_ <- autoCommit(); _ <- pull(remote,branch); res <- push(remote,branch) ) yield res }