relativeCommit = $relative_commit; return $this; } public function getRelativeCommit() { if ($this->relativeCommit === null) { list($err) = exec_manual( '(cd %s; git rev-parse --verify HEAD^)', $this->getPath()); if ($err) { $this->relativeCommit = self::GIT_MAGIC_ROOT_COMMIT; } else { $this->relativeCommit = 'HEAD^'; } } return $this->relativeCommit; } private function getDiffOptions() { $options = array( '-M', '-C', '--no-ext-diff', '--no-color', '--src-prefix=a/', '--dst-prefix=b/', '-U'.$this->getDiffLinesOfContext(), ); return implode(' ', $options); } public function getFullGitDiff() { $options = $this->getDiffOptions(); list($stdout) = execx( "(cd %s; git diff {$options} %s --)", $this->getPath(), $this->getRelativeCommit()); return $stdout; } public function getRawDiffText($path) { $relative_commit = $this->getRelativeCommit(); $options = $this->getDiffOptions(); list($stdout) = execx( "(cd %s; git diff {$options} %s -- %s)", $this->getPath(), $this->getRelativeCommit(), $path); return $stdout; } public function getBranchName() { // TODO: consider: // // $ git rev-parse --abbrev-ref `git symbolic-ref HEAD` // // But that may fail if you're not on a branch. list($stdout) = execx( '(cd %s; git branch)', $this->getPath()); $matches = null; if (preg_match('/^\* (.+)$/m', $stdout, $matches)) { return $matches[1]; } return null; } public function getSourceControlPath() { // TODO: Try to get something useful here. return null; } public function getGitCommitLog() { $relative = $this->getRelativeCommit(); if ($relative == self::GIT_MAGIC_ROOT_COMMIT) { list($stdout) = execx( '(cd %s; git log --format=medium HEAD)', $this->getPath()); } else { list($stdout) = execx( '(cd %s; git log --format=medium %s..HEAD)', $this->getPath(), $this->getRelativeCommit()); } return $stdout; } public function getGitHistoryLog() { list($stdout) = execx( '(cd %s; git log --format=medium -n%d %s)', $this->getPath(), self::SEARCH_LENGTH_FOR_PARENT_REVISIONS, $this->getRelativeCommit()); return $stdout; } public function getSourceControlBaseRevision() { list($stdout) = execx( '(cd %s; git rev-parse %s)', $this->getPath(), $this->getRelativeCommit()); return rtrim($stdout, "\n"); } /** * Returns the sha1 of the HEAD revision * @param boolean $short whether return the abbreviated or full hash. */ public function getGitHeadRevision($short=false) { if ($short) { $flags = '--short'; } else { $flags = ''; } list($stdout) = execx( '(cd %s; git rev-parse %s HEAD)', $this->getPath(), $flags); return rtrim($stdout, "\n"); } public function getWorkingCopyStatus() { if (!isset($this->status)) { // Find committed changes. list($stdout) = execx( '(cd %s; git diff --no-ext-diff --raw %s --)', $this->getPath(), $this->getRelativeCommit()); $files = $this->parseGitStatus($stdout); // Find uncommitted changes. list($stdout) = execx( '(cd %s; git diff --no-ext-diff --raw HEAD --)', $this->getPath()); $uncommitted_files = $this->parseGitStatus($stdout); foreach ($uncommitted_files as $path => $mask) { $mask |= self::FLAG_UNCOMMITTED; if (!isset($files[$path])) { $files[$path] = 0; } $files[$path] |= $mask; } // Find untracked files. list($stdout) = execx( '(cd %s; git ls-files --others --exclude-standard)', $this->getPath()); $stdout = rtrim($stdout, "\n"); if (strlen($stdout)) { $stdout = explode("\n", $stdout); foreach ($stdout as $file) { $files[$file] = self::FLAG_UNTRACKED; } } // TODO: This doesn't list unstaged adds. It's not clear how to get that // list other than "git status --porcelain" and then parsing it. :/ // Find unstaged changes. list($stdout) = execx( '(cd %s; git ls-files -m)', $this->getPath()); $stdout = rtrim($stdout, "\n"); if (strlen($stdout)) { $stdout = explode("\n", $stdout); foreach ($stdout as $file) { $files[$file] = isset($files[$file]) ? ($files[$file] | self::FLAG_UNSTAGED) : self::FLAG_UNSTAGED; } } $this->status = $files; } return $this->status; } public function amendGitHeadCommit($message) { execx( '(cd %s; git commit --amend --message %s)', $this->getPath(), $message); } public function getPreReceiveHookStatus($old_ref, $new_ref) { list($stdout) = execx( '(cd %s && git diff --no-ext-diff --raw %s %s --)', $this->getPath(), $old_ref, $new_ref); return $this->parseGitStatus($stdout, $full = true); } private function parseGitStatus($status, $full = false) { static $flags = array( 'A' => self::FLAG_ADDED, 'M' => self::FLAG_MODIFIED, 'D' => self::FLAG_DELETED, ); $status = trim($status); $lines = array(); foreach (explode("\n", $status) as $line) { if ($line) { $lines[] = preg_split("/[ \t]/", $line); } } $files = array(); foreach ($lines as $line) { $mask = 0; $flag = $line[4]; $file = $line[5]; foreach ($flags as $key => $bits) { if ($flag == $key) { $mask |= $bits; } } if ($full) { $files[$file] = array( 'mask' => $mask, 'ref' => rtrim($line[3], '.'), ); } else { $files[$file] = $mask; } } return $files; } public function getBlame($path) { // TODO: 'git blame' supports --porcelain and we should probably use it. list($stdout) = execx( '(cd %s; git blame -w -C %s -- %s)', $this->getPath(), $this->getRelativeCommit(), $path); $blame = array(); foreach (explode("\n", trim($stdout)) as $line) { if (!strlen($line)) { continue; } // lines predating a git repo's history are blamed to the oldest revision, // with the commit hash prepended by a ^. we shouldn't count these lines // as blaming to the oldest diff's unfortunate author if ($line[0] == '^') { continue; } $matches = null; $ok = preg_match( '/^([0-9a-f]+)[^(]+?[(](.*?) +\d\d\d\d-\d\d-\d\d/', $line, $matches); if (!$ok) { throw new Exception("Bad blame? `{$line}'"); } $revision = $matches[1]; $author = $matches[2]; $blame[] = array($author, $revision); } return $blame; } public function getOriginalFileData($path) { return $this->getFileDataAtRevision($path, $this->getRelativeCommit()); } public function getCurrentFileData($path) { return $this->getFileDataAtRevision($path, 'HEAD'); } private function parseGitTree($stdout) { $result = array(); $stdout = trim($stdout); if (!strlen($stdout)) { return $result; } $lines = explode("\n", $stdout); foreach ($lines as $line) { $matches = array(); $ok = preg_match( '/^(\d{6}) (blob|tree) ([a-z0-9]{40})[\t](.*)$/', $line, $matches); if (!$ok) { throw new Exception("Failed to parse git ls-tree output!"); } $result[$matches[4]] = array( 'mode' => $matches[1], 'type' => $matches[2], 'ref' => $matches[3], ); } return $result; } private function getFileDataAtRevision($path, $revision) { // NOTE: We don't want to just "git show {$revision}:{$path}" since if the // path was a directory at the given revision we'll get a list of its files // and treat it as though it as a file containing a list of other files, // which is silly. list($stdout) = execx( '(cd %s && git ls-tree %s -- %s)', $this->getPath(), $revision, $path); $info = $this->parseGitTree($stdout); if (empty($info[$path])) { // No such path, or the path is a directory and we executed 'ls-tree dir/' // and got a list of its contents back. return null; } if ($info[$path]['type'] != 'blob') { // Path is or was a directory, not a file. return null; } list($stdout) = execx( '(cd %s && git cat-file blob %s)', $this->getPath(), $info[$path]['ref']); return $stdout; } }