Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F2890008
ArcanistGitAPI.php
No One
Temporary
Actions
Download File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Advanced/Developer...
View Handle
View Hovercard
Size
53 KB
Referenced Files
None
Subscribers
None
ArcanistGitAPI.php
View Options
<?php
/**
* Interfaces with Git working copies.
*/
final
class
ArcanistGitAPI
extends
ArcanistRepositoryAPI
{
private
$repositoryHasNoCommits
=
false
;
const
SEARCH_LENGTH_FOR_PARENT_REVISIONS
=
16
;
/**
* For the repository's initial commit, 'git diff HEAD^' and similar do
* not work. Using this instead does work; it is the hash of the empty tree.
*/
const
GIT_MAGIC_ROOT_COMMIT
=
'4b825dc642cb6eb9a060e54bf8d69288fbee4904'
;
private
$symbolicHeadCommit
;
private
$resolvedHeadCommit
;
protected
function
buildLocalFuture
(
array
$argv
)
{
$argv
[
0
]
=
'git '
.
$argv
[
0
]
;
return
newv
(
'ExecFuture'
,
$argv
)
->
setCWD
(
$this
->
getPath
(
)
)
;
}
public
function
newPassthru
(
$pattern
/* , ... */
)
{
$args
=
func_get_args
(
)
;
static
$git
=
null
;
if
(
$git
===
null
)
{
if
(
phutil_is_windows
(
)
)
{
// NOTE: On Windows, phutil_passthru() uses 'bypass_shell' because
// everything goes to hell if we don't. We must provide an absolute
// path to Git for this to work properly.
$git
=
Filesystem
::
resolveBinary
(
'git'
)
;
$git
=
csprintf
(
'%s'
,
$git
)
;
}
else
{
$git
=
'git'
;
}
}
$args
[
0
]
=
$git
.
' '
.
$args
[
0
]
;
return
newv
(
'PhutilExecPassthru'
,
$args
)
->
setCWD
(
$this
->
getPath
(
)
)
;
}
public
function
getSourceControlSystemName
(
)
{
return
'git'
;
}
public
function
getGitVersion
(
)
{
static
$version
=
null
;
if
(
$version
===
null
)
{
list
(
$stdout
)
=
$this
->
execxLocal
(
'--version'
)
;
$version
=
rtrim
(
str_replace
(
'git version '
,
''
,
$stdout
)
)
;
}
return
$version
;
}
public
function
getMetadataPath
(
)
{
static
$path
=
null
;
if
(
$path
===
null
)
{
list
(
$stdout
)
=
$this
->
execxLocal
(
'rev-parse --git-dir'
)
;
$path
=
rtrim
(
$stdout
,
"\n"
)
;
// the output of git rev-parse --git-dir is an absolute path, unless
// the cwd is the root of the repository, in which case it uses the
// relative path of .git. If we get this relative path, turn it into
// an absolute path.
if
(
$path
===
'.git'
)
{
$path
=
$this
->
getPath
(
'.git'
)
;
}
}
return
$path
;
}
public
function
getHasCommits
(
)
{
return
!
$this
->
repositoryHasNoCommits
;
}
/**
* Tests if a child commit is descendant of a parent commit.
* If child and parent are the same, it returns false.
* @param Child commit SHA.
* @param Parent commit SHA.
* @return bool True if the child is a descendant of the parent.
*/
private
function
isDescendant
(
$child
,
$parent
)
{
list
(
$common_ancestor
)
=
$this
->
execxLocal
(
'merge-base %s %s'
,
$child
,
$parent
)
;
$common_ancestor
=
trim
(
$common_ancestor
)
;
return
(
$common_ancestor
==
$parent
)
&&
(
$common_ancestor
!=
$child
)
;
}
public
function
getLocalCommitInformation
(
)
{
if
(
$this
->
repositoryHasNoCommits
)
{
// Zero commits.
throw
new
Exception
(
pht
(
"You can't get local commit information for a repository with no "
.
"commits."
)
)
;
}
else
if
(
$this
->
getBaseCommit
(
)
==
self
::
GIT_MAGIC_ROOT_COMMIT
)
{
// One commit.
$against
=
'HEAD'
;
}
else
{
// 2..N commits. We include commits reachable from HEAD which are
// not reachable from the base commit; this is consistent with user
// expectations even though it is not actually the diff range.
// Particularly:
//
// |
// D <----- master branch
// |
// C Y <- feature branch
// | /|
// B X
// | /
// A
// |
//
// If "A, B, C, D" are master, and the user is at Y, when they run
// "arc diff B" they want (and get) a diff of B vs Y, but they think about
// this as being the commits X and Y. If we log "B..Y", we only show
// Y. With "Y --not B", we show X and Y.
if
(
$this
->
symbolicHeadCommit
!==
null
)
{
$base_commit
=
$this
->
getBaseCommit
(
)
;
$resolved_base
=
$this
->
resolveCommit
(
$base_commit
)
;
$head_commit
=
$this
->
symbolicHeadCommit
;
$resolved_head
=
$this
->
getHeadCommit
(
)
;
if
(
!
$this
->
isDescendant
(
$resolved_head
,
$resolved_base
)
)
{
// NOTE: Since the base commit will have been resolved as the
// merge-base of the specified base and the specified HEAD, we can't
// easily tell exactly what's wrong with the range.
// For example, `arc diff HEAD --head HEAD^^^` is invalid because it
// is reversed, but resolving the commit "HEAD" will compute its
// merge-base with "HEAD^^^", which is "HEAD^^^", so the range will
// appear empty.
throw
new
ArcanistUsageException
(
pht
(
'The specified commit range is empty, backward or invalid: the '
.
'base (%s) is not an ancestor of the head (%s). You can not '
.
'diff an empty or reversed commit range.'
,
$base_commit
,
$head_commit
)
)
;
}
}
$against
=
csprintf
(
'%s --not %s'
,
$this
->
getHeadCommit
(
)
,
$this
->
getBaseCommit
(
)
)
;
}
// NOTE: Windows escaping of "%" symbols apparently is inherently broken;
// when passed through escapeshellarg() they are replaced with spaces.
// TODO: Learn how cmd.exe works and find some clever workaround?
// NOTE: If we use "%x00", output is truncated in Windows.
list
(
$info
)
=
$this
->
execxLocal
(
phutil_is_windows
(
)
?
'log %C --format=%C --'
:
'log %C --format=%s --'
,
$against
,
// NOTE: "%B" is somewhat new, use "%s%n%n%b" instead.
'%H%x01%T%x01%P%x01%at%x01%an%x01%aE%x01%s%x01%s%n%n%b%x02'
)
;
$commits
=
array
(
)
;
$info
=
trim
(
$info
,
" \n\2"
)
;
if
(
!
strlen
(
$info
)
)
{
return
array
(
)
;
}
$info
=
explode
(
"\2"
,
$info
)
;
foreach
(
$info
as
$line
)
{
list
(
$commit
,
$tree
,
$parents
,
$time
,
$author
,
$author_email
,
$title
,
$message
)
=
explode
(
"\1"
,
trim
(
$line
)
,
8
)
;
$message
=
rtrim
(
$message
)
;
$commits
[
$commit
]
=
array
(
'commit'
=>
$commit
,
'tree'
=>
$tree
,
'parents'
=>
array_filter
(
explode
(
' '
,
$parents
)
)
,
'time'
=>
$time
,
'author'
=>
$author
,
'summary'
=>
$title
,
'message'
=>
$message
,
'authorEmail'
=>
$author_email
,
)
;
}
return
$commits
;
}
protected
function
buildBaseCommit
(
$symbolic_commit
)
{
if
(
$symbolic_commit
!==
null
)
{
if
(
$symbolic_commit
==
self
::
GIT_MAGIC_ROOT_COMMIT
)
{
$this
->
setBaseCommitExplanation
(
pht
(
'you explicitly specified the empty tree.'
)
)
;
return
$symbolic_commit
;
}
list
(
$err
,
$merge_base
)
=
$this
->
execManualLocal
(
'merge-base %s %s'
,
$symbolic_commit
,
$this
->
getHeadCommit
(
)
)
;
if
(
$err
)
{
throw
new
ArcanistUsageException
(
pht
(
"Unable to find any git commit named '%s' in this repository."
,
$symbolic_commit
)
)
;
}
if
(
$this
->
symbolicHeadCommit
===
null
)
{
$this
->
setBaseCommitExplanation
(
pht
(
"it is the merge-base of the explicitly specified base commit "
.
"'%s' and HEAD."
,
$symbolic_commit
)
)
;
}
else
{
$this
->
setBaseCommitExplanation
(
pht
(
"it is the merge-base of the explicitly specified base commit "
.
"'%s' and the explicitly specified head commit '%s'."
,
$symbolic_commit
,
$this
->
symbolicHeadCommit
)
)
;
}
return
trim
(
$merge_base
)
;
}
// Detect zero-commit or one-commit repositories. There is only one
// relative-commit value that makes any sense in these repositories: the
// empty tree.
list
(
$err
)
=
$this
->
execManualLocal
(
'rev-parse --verify HEAD^'
)
;
if
(
$err
)
{
list
(
$err
)
=
$this
->
execManualLocal
(
'rev-parse --verify HEAD'
)
;
if
(
$err
)
{
$this
->
repositoryHasNoCommits
=
true
;
}
if
(
$this
->
repositoryHasNoCommits
)
{
$this
->
setBaseCommitExplanation
(
pht
(
'the repository has no commits.'
)
)
;
}
else
{
$this
->
setBaseCommitExplanation
(
pht
(
'the repository has only one commit.'
)
)
;
}
return
self
::
GIT_MAGIC_ROOT_COMMIT
;
}
if
(
$this
->
getBaseCommitArgumentRules
(
)
||
$this
->
getConfigurationManager
(
)
->
getConfigFromAnySource
(
'base'
)
)
{
$base
=
$this
->
resolveBaseCommit
(
)
;
if
(
!
$base
)
{
throw
new
ArcanistUsageException
(
pht
(
"None of the rules in your 'base' configuration matched a valid "
.
"commit. Adjust rules or specify which commit you want to use "
.
"explicitly."
)
)
;
}
return
$base
;
}
$do_write
=
false
;
$default_relative
=
null
;
$working_copy
=
$this
->
getWorkingCopyIdentity
(
)
;
if
(
$working_copy
)
{
$default_relative
=
$working_copy
->
getProjectConfig
(
'git.default-relative-commit'
)
;
$this
->
setBaseCommitExplanation
(
pht
(
"it is the merge-base of '%s' and HEAD, as specified in '%s' in "
.
"'%s'. This setting overrides other settings."
,
$default_relative
,
'git.default-relative-commit'
,
'.arcconfig'
)
)
;
}
if
(
!
$default_relative
)
{
list
(
$err
,
$upstream
)
=
$this
->
execManualLocal
(
'rev-parse --abbrev-ref --symbolic-full-name %s'
,
'@{upstream}'
)
;
if
(
!
$err
)
{
$default_relative
=
trim
(
$upstream
)
;
$this
->
setBaseCommitExplanation
(
pht
(
"it is the merge-base of '%s' (the Git upstream "
.
"of the current branch) HEAD."
,
$default_relative
)
)
;
}
}
if
(
!
$default_relative
)
{
$default_relative
=
$this
->
readScratchFile
(
'default-relative-commit'
)
;
$default_relative
=
trim
(
$default_relative
)
;
if
(
$default_relative
)
{
$this
->
setBaseCommitExplanation
(
pht
(
"it is the merge-base of '%s' and HEAD, as specified in '%s'."
,
$default_relative
,
'.git/arc/default-relative-commit'
)
)
;
}
}
if
(
!
$default_relative
)
{
// TODO: Remove the history lesson soon.
echo
phutil_console_format
(
"<bg:green>** %s **</bg>\n\n"
,
pht
(
'Select a Default Commit Range'
)
)
;
echo
phutil_console_wrap
(
pht
(
"You're running a command which operates on a range of revisions "
.
"(usually, from some revision to HEAD) but have not specified the "
.
"revision that should determine the start of the range.\n\n"
.
"Previously, arc assumed you meant '%s' when you did not specify "
.
"a start revision, but this behavior does not make much sense in "
.
"most workflows outside of Facebook's historic %s workflow.\n\n"
.
"arc no longer assumes '%s'. You must specify a relative commit "
.
"explicitly when you invoke a command (e.g., `%s`, not just `%s`) "
.
"or select a default for this working copy.\n\nIn most cases, the "
.
"best default is '%s'. You can also select '%s' to preserve the "
.
"old behavior, or some other remote or branch. But you almost "
.
"certainly want to select 'origin/master'.\n\n"
.
"(Technically: the merge-base of the selected revision and HEAD is "
.
"used to determine the start of the commit range.)"
,
'HEAD^'
,
'git-svn'
,
'HEAD^'
,
'arc diff HEAD^'
,
'arc diff'
,
'origin/master'
,
'HEAD^'
)
)
;
$prompt
=
pht
(
'What default do you want to use? [origin/master]'
)
;
$default
=
phutil_console_prompt
(
$prompt
)
;
if
(
!
strlen
(
trim
(
$default
)
)
)
{
$default
=
'origin/master'
;
}
$default_relative
=
$default
;
$do_write
=
true
;
}
list
(
$object_type
)
=
$this
->
execxLocal
(
'cat-file -t %s'
,
$default_relative
)
;
if
(
trim
(
$object_type
)
!==
'commit'
)
{
throw
new
Exception
(
pht
(
"Relative commit '%s' is not the name of a commit!"
,
$default_relative
)
)
;
}
if
(
$do_write
)
{
// Don't perform this write until we've verified that the object is a
// valid commit name.
$this
->
writeScratchFile
(
'default-relative-commit'
,
$default_relative
)
;
$this
->
setBaseCommitExplanation
(
pht
(
"it is the merge-base of '%s' and HEAD, as you just specified."
,
$default_relative
)
)
;
}
list
(
$merge_base
)
=
$this
->
execxLocal
(
'merge-base %s HEAD'
,
$default_relative
)
;
return
trim
(
$merge_base
)
;
}
public
function
getHeadCommit
(
)
{
if
(
$this
->
resolvedHeadCommit
===
null
)
{
$this
->
resolvedHeadCommit
=
$this
->
resolveCommit
(
coalesce
(
$this
->
symbolicHeadCommit
,
'HEAD'
)
)
;
}
return
$this
->
resolvedHeadCommit
;
}
public
function
setHeadCommit
(
$symbolic_commit
)
{
$this
->
symbolicHeadCommit
=
$symbolic_commit
;
$this
->
reloadCommitRange
(
)
;
return
$this
;
}
/**
* Translates a symbolic commit (like "HEAD^") to a commit identifier.
* @param string_symbol commit.
* @return string the commit SHA.
*/
private
function
resolveCommit
(
$symbolic_commit
)
{
list
(
$err
,
$commit_hash
)
=
$this
->
execManualLocal
(
'rev-parse %s'
,
$symbolic_commit
)
;
if
(
$err
)
{
throw
new
ArcanistUsageException
(
pht
(
"Unable to find any git commit named '%s' in this repository."
,
$symbolic_commit
)
)
;
}
return
trim
(
$commit_hash
)
;
}
private
function
getDiffFullOptions
(
$detect_moves_and_renames
=
true
)
{
$options
=
array
(
self
::
getDiffBaseOptions
(
)
,
'--no-color'
,
'--src-prefix=a/'
,
'--dst-prefix=b/'
,
'-U'
.
$this
->
getDiffLinesOfContext
(
)
,
)
;
if
(
$detect_moves_and_renames
)
{
$options
[
]
=
'-M'
;
$options
[
]
=
'-C'
;
}
return
implode
(
' '
,
$options
)
;
}
private
function
getDiffBaseOptions
(
)
{
$options
=
array
(
// Disable external diff drivers, like graphical differs, since Arcanist
// needs to capture the diff text.
'--no-ext-diff'
,
// Disable textconv so we treat binary files as binary, even if they have
// an alternative textual representation. TODO: Ideally, Differential
// would ship up the binaries for 'arc patch' but display the textconv
// output in the visual diff.
'--no-textconv'
,
// Provide a standard view of submodule changes; the 'log' and 'diff'
// values do not parse by the diff parser.
'--submodule=short'
,
)
;
return
implode
(
' '
,
$options
)
;
}
/**
* @param the base revision
* @param head revision. If this is null, the generated diff will include the
* working copy
*/
public
function
getFullGitDiff
(
$base
,
$head
=
null
)
{
$options
=
$this
->
getDiffFullOptions
(
)
;
$config_options
=
array
(
)
;
// See T13432. Disable the rare "diff.suppressBlankEmpty" configuration
// option, which discards the " " (space) change type prefix on unchanged
// blank lines. At time of writing the parser does not handle these
// properly, but generating a more-standard diff is generally desirable
// even if a future parser handles this case more gracefully.
$config_options
[
]
=
'-c'
;
$config_options
[
]
=
'diff.suppressBlankEmpty=false'
;
if
(
$head
!==
null
)
{
list
(
$stdout
)
=
$this
->
execxLocal
(
"%LR diff {$options} %s %s --"
,
$config_options
,
$base
,
$head
)
;
}
else
{
list
(
$stdout
)
=
$this
->
execxLocal
(
"%LR diff {$options} %s --"
,
$config_options
,
$base
)
;
}
return
$stdout
;
}
/**
* @param string Path to generate a diff for.
* @param bool If true, detect moves and renames. Otherwise, ignore
* moves/renames; this is useful because it prompts git to
* generate real diff text.
*/
public
function
getRawDiffText
(
$path
,
$detect_moves_and_renames
=
true
)
{
$options
=
$this
->
getDiffFullOptions
(
$detect_moves_and_renames
)
;
list
(
$stdout
)
=
$this
->
execxLocal
(
"diff {$options} %s -- %s"
,
$this
->
getBaseCommit
(
)
,
$path
)
;
return
$stdout
;
}
private
function
getBranchNameFromRef
(
$ref
)
{
$count
=
0
;
$branch
=
preg_replace
(
'/^refs\/heads\//'
,
''
,
$ref
,
1
,
$count
)
;
if
(
$count
!==
1
)
{
return
null
;
}
if
(
!
strlen
(
$branch
)
)
{
return
null
;
}
return
$branch
;
}
public
function
getBranchName
(
)
{
list
(
$err
,
$stdout
,
$stderr
)
=
$this
->
execManualLocal
(
'symbolic-ref --quiet HEAD'
)
;
if
(
$err
===
0
)
{
// We expect the branch name to come qualified with a refs/heads/ prefix.
// Verify this, and strip it.
$ref
=
rtrim
(
$stdout
)
;
$branch
=
$this
->
getBranchNameFromRef
(
$ref
)
;
if
(
$branch
===
null
)
{
throw
new
Exception
(
pht
(
'Failed to parse %s output!'
,
'git symbolic-ref'
)
)
;
}
return
$branch
;
}
else
if
(
$err
===
1
)
{
// Exit status 1 with --quiet indicates that HEAD is detached.
return
null
;
}
else
{
throw
new
Exception
(
pht
(
'Command %s failed: %s'
,
'git symbolic-ref'
,
$stderr
)
)
;
}
}
public
function
getRemoteURI
(
)
{
// Determine which remote to examine; default to 'origin'
$remote
=
'origin'
;
$branch
=
$this
->
getBranchName
(
)
;
if
(
$branch
)
{
$path
=
$this
->
getPathToUpstream
(
$branch
)
;
if
(
$path
->
isConnectedToRemote
(
)
)
{
$remote
=
$path
->
getRemoteRemoteName
(
)
;
}
}
return
$this
->
getGitRemoteFetchURI
(
$remote
)
;
}
public
function
getSourceControlPath
(
)
{
// TODO: Try to get something useful here.
return
null
;
}
public
function
getGitCommitLog
(
)
{
$relative
=
$this
->
getBaseCommit
(
)
;
if
(
$this
->
repositoryHasNoCommits
)
{
// No commits yet.
return
''
;
}
else
if
(
$relative
==
self
::
GIT_MAGIC_ROOT_COMMIT
)
{
// First commit.
list
(
$stdout
)
=
$this
->
execxLocal
(
'log --format=medium HEAD'
)
;
}
else
{
// 2..N commits.
list
(
$stdout
)
=
$this
->
execxLocal
(
'log --first-parent --format=medium %s..%s'
,
$this
->
getBaseCommit
(
)
,
$this
->
getHeadCommit
(
)
)
;
}
return
$stdout
;
}
public
function
getGitHistoryLog
(
)
{
list
(
$stdout
)
=
$this
->
execxLocal
(
'log --format=medium -n%d %s'
,
self
::
SEARCH_LENGTH_FOR_PARENT_REVISIONS
,
$this
->
getBaseCommit
(
)
)
;
return
$stdout
;
}
public
function
getSourceControlBaseRevision
(
)
{
list
(
$stdout
)
=
$this
->
execxLocal
(
'rev-parse %s'
,
$this
->
getBaseCommit
(
)
)
;
return
rtrim
(
$stdout
,
"\n"
)
;
}
public
function
getCanonicalRevisionName
(
$string
)
{
$match
=
null
;
if
(
preg_match
(
'/@([0-9]+)$/'
,
$string
,
$match
)
)
{
$stdout
=
$this
->
getHashFromFromSVNRevisionNumber
(
$match
[
1
]
)
;
}
else
{
list
(
$stdout
)
=
$this
->
execxLocal
(
phutil_is_windows
(
)
?
'show -s --format=%C %s --'
:
'show -s --format=%s %s --'
,
'%H'
,
$string
)
;
}
return
rtrim
(
$stdout
)
;
}
private
function
executeSVNFindRev
(
$input
,
$vcs
)
{
$match
=
array
(
)
;
list
(
$stdout
)
=
$this
->
execxLocal
(
'svn find-rev %s'
,
$input
)
;
if
(
!
$stdout
)
{
throw
new
ArcanistUsageException
(
pht
(
'Cannot find the %s equivalent of %s.'
,
$vcs
,
$input
)
)
;
}
// When git performs a partial-rebuild during svn
// look-up, we need to parse the final line
$lines
=
explode
(
"\n"
,
$stdout
)
;
$stdout
=
$lines
[
count
(
$lines
)
-
2
]
;
return
rtrim
(
$stdout
)
;
}
// Convert svn revision number to git hash
public
function
getHashFromFromSVNRevisionNumber
(
$revision_id
)
{
return
$this
->
executeSVNFindRev
(
'r'
.
$revision_id
,
'Git'
)
;
}
// Convert a git hash to svn revision number
public
function
getSVNRevisionNumberFromHash
(
$hash
)
{
return
$this
->
executeSVNFindRev
(
$hash
,
'SVN'
)
;
}
private
function
buildUncommittedStatusViaStatus
(
)
{
$status
=
$this
->
buildLocalFuture
(
array
(
'status --porcelain=2 -z'
,
)
)
;
list
(
$stdout
)
=
$status
->
resolvex
(
)
;
$result
=
new
PhutilArrayWithDefaultValue
(
)
;
$parts
=
explode
(
"\0"
,
$stdout
)
;
while
(
count
(
$parts
)
>
1
)
{
$entry
=
array_shift
(
$parts
)
;
$entry_parts
=
explode
(
' '
,
$entry
,
2
)
;
if
(
$entry_parts
[
0
]
==
'1'
)
{
$entry_parts
=
explode
(
' '
,
$entry
,
9
)
;
$path
=
$entry_parts
[
8
]
;
}
else
if
(
$entry_parts
[
0
]
==
'2'
)
{
$entry_parts
=
explode
(
' '
,
$entry
,
10
)
;
$path
=
$entry_parts
[
9
]
;
}
else
if
(
$entry_parts
[
0
]
==
'u'
)
{
$entry_parts
=
explode
(
' '
,
$entry
,
11
)
;
$path
=
$entry_parts
[
10
]
;
}
else
if
(
$entry_parts
[
0
]
==
'?'
)
{
$entry_parts
=
explode
(
' '
,
$entry
,
2
)
;
$result
[
$entry_parts
[
1
]
]
=
self
::
FLAG_UNTRACKED
;
continue
;
}
$result
[
$path
]
|=
self
::
FLAG_UNCOMMITTED
;
$index_state
=
substr
(
$entry_parts
[
1
]
,
0
,
1
)
;
$working_state
=
substr
(
$entry_parts
[
1
]
,
1
,
1
)
;
if
(
$index_state
==
'A'
)
{
$result
[
$path
]
|=
self
::
FLAG_ADDED
;
}
else
if
(
$index_state
==
'M'
)
{
$result
[
$path
]
|=
self
::
FLAG_MODIFIED
;
}
else
if
(
$index_state
==
'D'
)
{
$result
[
$path
]
|=
self
::
FLAG_DELETED
;
}
if
(
$working_state
!=
'.'
)
{
$result
[
$path
]
|=
self
::
FLAG_UNSTAGED
;
if
(
$index_state
==
'.'
)
{
if
(
$working_state
==
'A'
)
{
$result
[
$path
]
|=
self
::
FLAG_ADDED
;
}
else
if
(
$working_state
==
'M'
)
{
$result
[
$path
]
|=
self
::
FLAG_MODIFIED
;
}
else
if
(
$working_state
==
'D'
)
{
$result
[
$path
]
|=
self
::
FLAG_DELETED
;
}
}
}
$submodule_tracked
=
substr
(
$entry_parts
[
2
]
,
2
,
1
)
;
$submodule_untracked
=
substr
(
$entry_parts
[
2
]
,
3
,
1
)
;
if
(
$submodule_tracked
==
'M'
||
$submodule_untracked
==
'U'
)
{
$result
[
$path
]
|=
self
::
FLAG_EXTERNALS
;
}
if
(
$entry_parts
[
0
]
==
'2'
)
{
$result
[
array_shift
(
$parts
)
]
=
$result
[
$path
]
|
self
::
FLAG_DELETED
;
$result
[
$path
]
|=
self
::
FLAG_ADDED
;
}
}
return
$result
->
toArray
(
)
;
}
protected
function
buildUncommittedStatus
(
)
{
if
(
version_compare
(
$this
->
getGitVersion
(
)
,
'2.11.0'
,
'>='
)
)
{
return
$this
->
buildUncommittedStatusViaStatus
(
)
;
}
$diff_options
=
$this
->
getDiffBaseOptions
(
)
;
if
(
$this
->
repositoryHasNoCommits
)
{
$diff_base
=
self
::
GIT_MAGIC_ROOT_COMMIT
;
}
else
{
$diff_base
=
'HEAD'
;
}
// Find uncommitted changes.
$uncommitted_future
=
$this
->
buildLocalFuture
(
array
(
'diff %C --raw %s --'
,
$diff_options
,
$diff_base
,
)
)
;
$untracked_future
=
$this
->
buildLocalFuture
(
array
(
'ls-files --others --exclude-standard'
,
)
)
;
// Unstaged changes
$unstaged_future
=
$this
->
buildLocalFuture
(
array
(
'diff-files --name-only'
,
)
)
;
$futures
=
array
(
$uncommitted_future
,
$untracked_future
,
// NOTE: `git diff-files` races with each of these other commands
// internally, and resolves with inconsistent results if executed
// in parallel. To work around this, DO NOT run it at the same time.
// After the other commands exit, we can start the `diff-files` command.
)
;
id
(
new
FutureIterator
(
$futures
)
)
->
resolveAll
(
)
;
// We're clear to start the `git diff-files` now.
$unstaged_future
->
start
(
)
;
$result
=
new
PhutilArrayWithDefaultValue
(
)
;
list
(
$stdout
)
=
$uncommitted_future
->
resolvex
(
)
;
$uncommitted_files
=
$this
->
parseGitRawDiff
(
$stdout
)
;
foreach
(
$uncommitted_files
as
$path
=>
$mask
)
{
$result
[
$path
]
|=
(
$mask
|
self
::
FLAG_UNCOMMITTED
)
;
}
list
(
$stdout
)
=
$untracked_future
->
resolvex
(
)
;
$stdout
=
rtrim
(
$stdout
,
"\n"
)
;
if
(
strlen
(
$stdout
)
)
{
$stdout
=
explode
(
"\n"
,
$stdout
)
;
foreach
(
$stdout
as
$path
)
{
$result
[
$path
]
|=
self
::
FLAG_UNTRACKED
;
}
}
list
(
$stdout
,
$stderr
)
=
$unstaged_future
->
resolvex
(
)
;
$stdout
=
rtrim
(
$stdout
,
"\n"
)
;
if
(
strlen
(
$stdout
)
)
{
$stdout
=
explode
(
"\n"
,
$stdout
)
;
foreach
(
$stdout
as
$path
)
{
$result
[
$path
]
|=
self
::
FLAG_UNSTAGED
;
}
}
return
$result
->
toArray
(
)
;
}
protected
function
buildCommitRangeStatus
(
)
{
list
(
$stdout
,
$stderr
)
=
$this
->
execxLocal
(
'diff %C --raw %s HEAD --'
,
$this
->
getDiffBaseOptions
(
)
,
$this
->
getBaseCommit
(
)
)
;
return
$this
->
parseGitRawDiff
(
$stdout
)
;
}
public
function
getGitConfig
(
$key
,
$default
=
null
)
{
list
(
$err
,
$stdout
)
=
$this
->
execManualLocal
(
'config %s'
,
$key
)
;
if
(
$err
)
{
return
$default
;
}
return
rtrim
(
$stdout
)
;
}
public
function
getAuthor
(
)
{
list
(
$stdout
)
=
$this
->
execxLocal
(
'var GIT_AUTHOR_IDENT'
)
;
return
preg_replace
(
'/\s+<.*/'
,
''
,
rtrim
(
$stdout
,
"\n"
)
)
;
}
public
function
addToCommit
(
array
$paths
)
{
$this
->
execxLocal
(
'add -A -- %Ls'
,
$paths
)
;
$this
->
reloadWorkingCopy
(
)
;
return
$this
;
}
public
function
doCommit
(
$message
)
{
$tmp_file
=
new
TempFile
(
)
;
Filesystem
::
writeFile
(
$tmp_file
,
$message
)
;
// NOTE: "--allow-empty-message" was introduced some time after 1.7.0.4,
// so we do not provide it and thus require a message.
$this
->
execxLocal
(
'commit -F %s'
,
$tmp_file
)
;
$this
->
reloadWorkingCopy
(
)
;
return
$this
;
}
public
function
amendCommit
(
$message
=
null
)
{
if
(
$message
===
null
)
{
$this
->
execxLocal
(
'commit --amend --allow-empty -C HEAD'
)
;
}
else
{
$tmp_file
=
new
TempFile
(
)
;
Filesystem
::
writeFile
(
$tmp_file
,
$message
)
;
$this
->
execxLocal
(
'commit --amend --allow-empty -F %s'
,
$tmp_file
)
;
}
$this
->
reloadWorkingCopy
(
)
;
return
$this
;
}
private
function
parseGitRawDiff
(
$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
,
6
)
;
}
}
$files
=
array
(
)
;
foreach
(
$lines
as
$line
)
{
$mask
=
0
;
// "git diff --raw" lines begin with a ":" character.
$old_mode
=
ltrim
(
$line
[
0
]
,
':'
)
;
$new_mode
=
$line
[
1
]
;
// The hashes may be padded with "." characters for alignment. Discard
// them.
$old_hash
=
rtrim
(
$line
[
2
]
,
'.'
)
;
$new_hash
=
rtrim
(
$line
[
3
]
,
'.'
)
;
$flag
=
$line
[
4
]
;
$file
=
$line
[
5
]
;
$new_value
=
intval
(
$new_mode
,
8
)
;
$is_submodule
=
(
(
$new_value
&
0160000
)
===
0160000
)
;
if
(
(
$is_submodule
)
&&
(
$flag
==
'M'
)
&&
(
$old_hash
===
$new_hash
)
&&
(
$old_mode
===
$new_mode
)
)
{
// See T9455. We see this submodule as "modified", but the old and new
// hashes are the same and the old and new modes are the same, so we
// don't directly see a modification.
// We can end up here if we have a submodule which has uncommitted
// changes inside of it (for example, the user has added untracked
// files or made uncommitted changes to files in the submodule). In
// this case, we set a different flag because we can't meaningfully
// give users the same prompt.
// Note that if the submodule has real changes from the parent
// perspective (the base commit has changed) and also has uncommitted
// changes, we'll only see the real changes and miss the uncommitted
// changes. At the time of writing, there is no reasonable porcelain
// for finding those changes, and the impact of this error seems small.
$mask
|=
self
::
FLAG_EXTERNALS
;
}
else
if
(
isset
(
$flags
[
$flag
]
)
)
{
$mask
|=
$flags
[
$flag
]
;
}
else
if
(
$flag
[
0
]
==
'R'
)
{
$both
=
explode
(
"\t"
,
$file
)
;
if
(
$full
)
{
$files
[
$both
[
0
]
]
=
array
(
'mask'
=>
$mask
|
self
::
FLAG_DELETED
,
'ref'
=>
str_repeat
(
'0'
,
40
)
,
)
;
}
else
{
$files
[
$both
[
0
]
]
=
$mask
|
self
::
FLAG_DELETED
;
}
$file
=
$both
[
1
]
;
$mask
|=
self
::
FLAG_ADDED
;
}
else
if
(
$flag
[
0
]
==
'C'
)
{
$both
=
explode
(
"\t"
,
$file
)
;
$file
=
$both
[
1
]
;
$mask
|=
self
::
FLAG_ADDED
;
}
if
(
$full
)
{
$files
[
$file
]
=
array
(
'mask'
=>
$mask
,
'ref'
=>
$new_hash
,
)
;
}
else
{
$files
[
$file
]
=
$mask
;
}
}
return
$files
;
}
public
function
getAllFiles
(
)
{
$future
=
$this
->
buildLocalFuture
(
array
(
'ls-files -z'
)
)
;
return
id
(
new
LinesOfALargeExecFuture
(
$future
)
)
->
setDelimiter
(
"\0"
)
;
}
public
function
getChangedFiles
(
$since_commit
)
{
list
(
$stdout
)
=
$this
->
execxLocal
(
'diff --raw %s'
,
$since_commit
)
;
return
$this
->
parseGitRawDiff
(
$stdout
)
;
}
public
function
getBlame
(
$path
)
{
list
(
$stdout
)
=
$this
->
execxLocal
(
'blame --porcelain -w -M %s -- %s'
,
$this
->
getBaseCommit
(
)
,
$path
)
;
// the --porcelain format prints at least one header line per source line,
// then the source line prefixed by a tab character
$blame_info
=
preg_split
(
'/^\t.*\n/m'
,
rtrim
(
$stdout
)
)
;
// commit info is not repeated in these headers, so cache it
$revision_data
=
array
(
)
;
$blame
=
array
(
)
;
foreach
(
$blame_info
as
$line_info
)
{
$revision
=
substr
(
$line_info
,
0
,
40
)
;
$data
=
idx
(
$revision_data
,
$revision
,
array
(
)
)
;
if
(
empty
(
$data
)
)
{
$matches
=
array
(
)
;
if
(
!
preg_match
(
'/^author (.*)$/m'
,
$line_info
,
$matches
)
)
{
throw
new
Exception
(
pht
(
'Unexpected output from %s: no author for commit %s'
,
'git blame'
,
$revision
)
)
;
}
$data
[
'author'
]
=
$matches
[
1
]
;
$data
[
'from_first_commit'
]
=
preg_match
(
'/^boundary$/m'
,
$line_info
)
;
$revision_data
[
$revision
]
=
$data
;
}
// Ignore lines predating the git repository (on a boundary commit)
// rather than blaming them on the oldest diff's unfortunate author
if
(
!
$data
[
'from_first_commit'
]
)
{
$blame
[
]
=
array
(
$data
[
'author'
]
,
$revision
)
;
}
}
return
$blame
;
}
public
function
getOriginalFileData
(
$path
)
{
return
$this
->
getFileDataAtRevision
(
$path
,
$this
->
getBaseCommit
(
)
)
;
}
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|commit) ([a-z0-9]{40})[\t](.*)$/'
,
$line
,
$matches
)
;
if
(
!
$ok
)
{
throw
new
Exception
(
pht
(
'Failed to parse %s output!'
,
'git ls-tree'
)
)
;
}
$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.
if
(
!
strlen
(
$path
)
)
{
// No filename, so there's no content (Probably new/deleted file).
return
null
;
}
list
(
$stdout
)
=
$this
->
execxLocal
(
'ls-tree %s -- %s'
,
$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
)
=
$this
->
execxLocal
(
'cat-file blob %s'
,
$info
[
$path
]
[
'ref'
]
)
;
return
$stdout
;
}
/**
* Returns names of all the branches in the current repository.
*
* @return list<dict<string, string>> Dictionary of branch information.
*/
public
function
getAllBranches
(
)
{
$field_list
=
array
(
'%(refname)'
,
'%(objectname)'
,
'%(committerdate:raw)'
,
'%(tree)'
,
'%(subject)'
,
'%(subject)%0a%0a%(body)'
,
'%02'
,
)
;
list
(
$stdout
)
=
$this
->
execxLocal
(
'for-each-ref --format=%s -- refs/heads'
,
implode
(
'%01'
,
$field_list
)
)
;
$current
=
$this
->
getBranchName
(
)
;
$result
=
array
(
)
;
$lines
=
explode
(
"\2"
,
$stdout
)
;
foreach
(
$lines
as
$line
)
{
$line
=
trim
(
$line
)
;
if
(
!
strlen
(
$line
)
)
{
continue
;
}
$fields
=
explode
(
"\1"
,
$line
,
6
)
;
list
(
$ref
,
$hash
,
$epoch
,
$tree
,
$desc
,
$text
)
=
$fields
;
$branch
=
$this
->
getBranchNameFromRef
(
$ref
)
;
if
(
$branch
!==
null
)
{
$result
[
]
=
array
(
'current'
=>
(
$branch
===
$current
)
,
'name'
=>
$branch
,
'ref'
=>
$ref
,
'hash'
=>
$hash
,
'tree'
=>
$tree
,
'epoch'
=>
(int)
$epoch
,
'desc'
=>
$desc
,
'text'
=>
$text
,
)
;
}
}
return
$result
;
}
public
function
getAllBranchRefs
(
)
{
$branches
=
$this
->
getAllBranches
(
)
;
$refs
=
array
(
)
;
foreach
(
$branches
as
$branch
)
{
$commit_ref
=
$this
->
newCommitRef
(
)
->
setCommitHash
(
$branch
[
'hash'
]
)
->
setTreeHash
(
$branch
[
'tree'
]
)
->
setCommitEpoch
(
$branch
[
'epoch'
]
)
->
attachMessage
(
$branch
[
'text'
]
)
;
$refs
[
]
=
$this
->
newBranchRef
(
)
->
setBranchName
(
$branch
[
'name'
]
)
->
setRefName
(
$branch
[
'ref'
]
)
->
setIsCurrentBranch
(
$branch
[
'current'
]
)
->
attachCommitRef
(
$commit_ref
)
;
}
return
$refs
;
}
public
function
getBaseCommitRef
(
)
{
$base_commit
=
$this
->
getBaseCommit
(
)
;
if
(
$base_commit
===
self
::
GIT_MAGIC_ROOT_COMMIT
)
{
return
null
;
}
$base_message
=
$this
->
getCommitMessage
(
$base_commit
)
;
// TODO: We should also pull the tree hash.
return
$this
->
newCommitRef
(
)
->
setCommitHash
(
$base_commit
)
->
attachMessage
(
$base_message
)
;
}
public
function
getWorkingCopyRevision
(
)
{
list
(
$stdout
)
=
$this
->
execxLocal
(
'rev-parse HEAD'
)
;
return
rtrim
(
$stdout
,
"\n"
)
;
}
public
function
isHistoryDefaultImmutable
(
)
{
return
false
;
}
public
function
supportsAmend
(
)
{
return
true
;
}
public
function
supportsCommitRanges
(
)
{
return
true
;
}
public
function
supportsLocalCommits
(
)
{
return
true
;
}
public
function
hasLocalCommit
(
$commit
)
{
try
{
if
(
!
$this
->
getCanonicalRevisionName
(
$commit
)
)
{
return
false
;
}
}
catch
(
CommandException
$exception
)
{
return
false
;
}
return
true
;
}
public
function
getAllLocalChanges
(
)
{
$diff
=
$this
->
getFullGitDiff
(
$this
->
getBaseCommit
(
)
)
;
if
(
!
strlen
(
trim
(
$diff
)
)
)
{
return
array
(
)
;
}
$parser
=
new
ArcanistDiffParser
(
)
;
return
$parser
->
parseDiff
(
$diff
)
;
}
public
function
getFinalizedRevisionMessage
(
)
{
return
pht
(
"You may now push this commit upstream, as appropriate (e.g. with "
.
"'%s', or '%s', or by printing and faxing it)."
,
'git push'
,
'git svn dcommit'
)
;
}
public
function
getCommitMessage
(
$commit
)
{
list
(
$message
)
=
$this
->
execxLocal
(
'log -n1 --format=%C %s --'
,
'%s%n%n%b'
,
$commit
)
;
return
$message
;
}
public
function
loadWorkingCopyDifferentialRevisions
(
ConduitClient
$conduit
,
array
$query
)
{
$messages
=
$this
->
getGitCommitLog
(
)
;
if
(
!
strlen
(
$messages
)
)
{
return
array
(
)
;
}
$parser
=
new
ArcanistDiffParser
(
)
;
$messages
=
$parser
->
parseDiff
(
$messages
)
;
// First, try to find revisions by explicit revision IDs in commit messages.
$reason_map
=
array
(
)
;
$revision_ids
=
array
(
)
;
foreach
(
$messages
as
$message
)
{
$object
=
ArcanistDifferentialCommitMessage
::
newFromRawCorpus
(
$message
->
getMetadata
(
'message'
)
)
;
if
(
$object
->
getRevisionID
(
)
)
{
$revision_ids
[
]
=
$object
->
getRevisionID
(
)
;
$reason_map
[
$object
->
getRevisionID
(
)
]
=
$message
->
getCommitHash
(
)
;
}
}
if
(
$revision_ids
)
{
$results
=
$conduit
->
callMethodSynchronous
(
'differential.query'
,
$query
+
array
(
'ids'
=>
$revision_ids
,
)
)
;
foreach
(
$results
as
$key
=>
$result
)
{
$hash
=
substr
(
$reason_map
[
$result
[
'id'
]
]
,
0
,
16
)
;
$results
[
$key
]
[
'why'
]
=
pht
(
"Commit message for '%s' has explicit 'Differential Revision'."
,
$hash
)
;
}
return
$results
;
}
// If we didn't succeed, try to find revisions by hash.
$hashes
=
array
(
)
;
foreach
(
$this
->
getLocalCommitInformation
(
)
as
$commit
)
{
$hashes
[
]
=
array
(
'gtcm'
,
$commit
[
'commit'
]
)
;
$hashes
[
]
=
array
(
'gttr'
,
$commit
[
'tree'
]
)
;
}
$results
=
$conduit
->
callMethodSynchronous
(
'differential.query'
,
$query
+
array
(
'commitHashes'
=>
$hashes
,
)
)
;
foreach
(
$results
as
$key
=>
$result
)
{
$results
[
$key
]
[
'why'
]
=
pht
(
'A git commit or tree hash in the commit range is already attached '
.
'to the Differential revision.'
)
;
}
return
$results
;
}
public
function
updateWorkingCopy
(
)
{
$this
->
execxLocal
(
'pull'
)
;
$this
->
reloadWorkingCopy
(
)
;
}
public
function
getCommitSummary
(
$commit
)
{
if
(
$commit
==
self
::
GIT_MAGIC_ROOT_COMMIT
)
{
return
pht
(
'(The Empty Tree)'
)
;
}
list
(
$summary
)
=
$this
->
execxLocal
(
'log -n 1 --format=%C %s'
,
'%s'
,
$commit
)
;
return
trim
(
$summary
)
;
}
public
function
isGitSubversionRepo
(
)
{
return
Filesystem
::
pathExists
(
$this
->
getPath
(
'.git/svn'
)
)
;
}
public
function
resolveBaseCommitRule
(
$rule
,
$source
)
{
list
(
$type
,
$name
)
=
explode
(
':'
,
$rule
,
2
)
;
switch
(
$type
)
{
case
'git'
:
$matches
=
null
;
if
(
preg_match
(
'/^merge-base\((.+)\)$/'
,
$name
,
$matches
)
)
{
list
(
$err
,
$merge_base
)
=
$this
->
execManualLocal
(
'merge-base %s HEAD'
,
$matches
[
1
]
)
;
if
(
!
$err
)
{
$this
->
setBaseCommitExplanation
(
pht
(
"it is the merge-base of '%s' and HEAD, as specified by "
.
"'%s' in your %s 'base' configuration."
,
$matches
[
1
]
,
$rule
,
$source
)
)
;
return
trim
(
$merge_base
)
;
}
}
else
if
(
preg_match
(
'/^branch-unique\((.+)\)$/'
,
$name
,
$matches
)
)
{
list
(
$err
,
$merge_base
)
=
$this
->
execManualLocal
(
'merge-base %s HEAD'
,
$matches
[
1
]
)
;
if
(
$err
)
{
return
null
;
}
$merge_base
=
trim
(
$merge_base
)
;
list
(
$commits
)
=
$this
->
execxLocal
(
'log --format=%C %s..HEAD --'
,
'%H'
,
$merge_base
)
;
$commits
=
array_filter
(
explode
(
"\n"
,
$commits
)
)
;
if
(
!
$commits
)
{
return
null
;
}
$commits
[
]
=
$merge_base
;
$head_branch_count
=
null
;
$all_branch_names
=
ipull
(
$this
->
getAllBranches
(
)
,
'name'
)
;
foreach
(
$commits
as
$commit
)
{
// Ideally, we would use something like "for-each-ref --contains"
// to get a filtered list of branches ready for script consumption.
// Instead, try to get predictable output from "branch --contains".
$flags
=
array
(
)
;
$flags
[
]
=
'--no-color'
;
// NOTE: The "--no-column" flag was introduced in Git 1.7.11, so
// don't pass it if we're running an older version. See T9953.
$version
=
$this
->
getGitVersion
(
)
;
if
(
version_compare
(
$version
,
'1.7.11'
,
'>='
)
)
{
$flags
[
]
=
'--no-column'
;
}
list
(
$branches
)
=
$this
->
execxLocal
(
'branch %Ls --contains %s'
,
$flags
,
$commit
)
;
$branches
=
array_filter
(
explode
(
"\n"
,
$branches
)
)
;
// Filter the list, removing the "current" marker (*) and ignoring
// anything other than known branch names (mainly, any possible
// "detached HEAD" or "no branch" line).
foreach
(
$branches
as
$key
=>
$branch
)
{
$branch
=
trim
(
$branch
,
' *'
)
;
if
(
in_array
(
$branch
,
$all_branch_names
)
)
{
$branches
[
$key
]
=
$branch
;
}
else
{
unset
(
$branches
[
$key
]
)
;
}
}
if
(
$head_branch_count
===
null
)
{
// If this is the first commit, it's HEAD. Count how many
// branches it is on; we want to include commits on the same
// number of branches. This covers a case where this branch
// has sub-branches and we're running "arc diff" here again
// for whatever reason.
$head_branch_count
=
count
(
$branches
)
;
}
else
if
(
count
(
$branches
)
>
$head_branch_count
)
{
$branches
=
implode
(
', '
,
$branches
)
;
$this
->
setBaseCommitExplanation
(
pht
(
"it is the first commit between '%s' (the merge-base of "
.
"'%s' and HEAD) which is also contained by another branch "
.
"(%s)."
,
$merge_base
,
$matches
[
1
]
,
$branches
)
)
;
return
$commit
;
}
}
}
else
{
list
(
$err
)
=
$this
->
execManualLocal
(
'cat-file -t %s'
,
$name
)
;
if
(
!
$err
)
{
$this
->
setBaseCommitExplanation
(
pht
(
"it is specified by '%s' in your %s 'base' configuration."
,
$rule
,
$source
)
)
;
return
$name
;
}
}
break
;
case
'arc'
:
switch
(
$name
)
{
case
'empty'
:
$this
->
setBaseCommitExplanation
(
pht
(
"you specified '%s' in your %s 'base' configuration."
,
$rule
,
$source
)
)
;
return
self
::
GIT_MAGIC_ROOT_COMMIT
;
case
'amended'
:
$text
=
$this
->
getCommitMessage
(
'HEAD'
)
;
$message
=
ArcanistDifferentialCommitMessage
::
newFromRawCorpus
(
$text
)
;
if
(
$message
->
getRevisionID
(
)
)
{
$this
->
setBaseCommitExplanation
(
pht
(
"HEAD has been amended with 'Differential Revision:', "
.
"as specified by '%s' in your %s 'base' configuration."
,
$rule
,
$source
)
)
;
return
'HEAD^'
;
}
break
;
case
'upstream'
:
list
(
$err
,
$upstream
)
=
$this
->
execManualLocal
(
'rev-parse --abbrev-ref --symbolic-full-name %s'
,
'@{upstream}'
)
;
if
(
!
$err
)
{
$upstream
=
rtrim
(
$upstream
)
;
list
(
$upstream_merge_base
)
=
$this
->
execxLocal
(
'merge-base %s HEAD'
,
$upstream
)
;
$upstream_merge_base
=
rtrim
(
$upstream_merge_base
)
;
$this
->
setBaseCommitExplanation
(
pht
(
"it is the merge-base of the upstream of the current branch "
.
"and HEAD, and matched the rule '%s' in your %s "
.
"'base' configuration."
,
$rule
,
$source
)
)
;
return
$upstream_merge_base
;
}
break
;
case
'this'
:
$this
->
setBaseCommitExplanation
(
pht
(
"you specified '%s' in your %s 'base' configuration."
,
$rule
,
$source
)
)
;
return
'HEAD^'
;
}
default
:
return
null
;
}
return
null
;
}
public
function
canStashChanges
(
)
{
return
true
;
}
public
function
stashChanges
(
)
{
$this
->
execxLocal
(
'stash'
)
;
$this
->
reloadWorkingCopy
(
)
;
}
public
function
unstashChanges
(
)
{
$this
->
execxLocal
(
'stash pop'
)
;
}
protected
function
didReloadCommitRange
(
)
{
// After an amend, the symbolic head may resolve to a different commit.
$this
->
resolvedHeadCommit
=
null
;
}
/**
* Follow the chain of tracking branches upstream until we reach a remote
* or cycle locally.
*
* @param string Ref to start from.
* @return ArcanistGitUpstreamPath Path to an upstream.
*/
public
function
getPathToUpstream
(
$start
)
{
$cursor
=
$start
;
$path
=
new
ArcanistGitUpstreamPath
(
)
;
while
(
true
)
{
list
(
$err
,
$upstream
)
=
$this
->
execManualLocal
(
'rev-parse --symbolic-full-name %s@{upstream}'
,
$cursor
)
;
if
(
$err
)
{
// We ended up somewhere with no tracking branch, so we're done.
break
;
}
$upstream
=
trim
(
$upstream
)
;
if
(
preg_match
(
'(^refs/heads/)'
,
$upstream
)
)
{
$upstream
=
preg_replace
(
'(^refs/heads/)'
,
''
,
$upstream
)
;
$is_cycle
=
$path
->
getUpstream
(
$upstream
)
;
$path
->
addUpstream
(
$cursor
,
array
(
'type'
=>
ArcanistGitUpstreamPath
::
TYPE_LOCAL
,
'name'
=>
$upstream
,
'cycle'
=>
$is_cycle
,
)
)
;
if
(
$is_cycle
)
{
// We ran into a local cycle, so we're done.
break
;
}
// We found another local branch, so follow that one upriver.
$cursor
=
$upstream
;
continue
;
}
if
(
preg_match
(
'(^refs/remotes/)'
,
$upstream
)
)
{
$upstream
=
preg_replace
(
'(^refs/remotes/)'
,
''
,
$upstream
)
;
list
(
$remote
,
$branch
)
=
explode
(
'/'
,
$upstream
,
2
)
;
$path
->
addUpstream
(
$cursor
,
array
(
'type'
=>
ArcanistGitUpstreamPath
::
TYPE_REMOTE
,
'name'
=>
$branch
,
'remote'
=>
$remote
,
)
)
;
// We found a remote, so we're done.
break
;
}
throw
new
Exception
(
pht
(
'Got unrecognized upstream format ("%s") from Git, expected '
.
'"refs/heads/..." or "refs/remotes/...".'
,
$upstream
)
)
;
}
return
$path
;
}
public
function
isPerforceRemote
(
$remote_name
)
{
// See T13434. In Perforce workflows, "git p4 clone" creates "p4" refs
// under "refs/remotes/", but does not define a real remote named "p4".
// We treat this remote as though it were a real remote during "arc land",
// but it does not respond to commands like "git remote show p4", so we
// need to handle it specially.
if
(
$remote_name
!==
'p4'
)
{
return
false
;
}
$remote_dir
=
$this
->
getMetadataPath
(
)
.
'/refs/remotes/p4'
;
if
(
!
Filesystem
::
pathExists
(
$remote_dir
)
)
{
return
false
;
}
return
true
;
}
public
function
isPushableRemote
(
$remote_name
)
{
$uri
=
$this
->
getGitRemotePushURI
(
$remote_name
)
;
return
(
$uri
!==
null
)
;
}
public
function
isFetchableRemote
(
$remote_name
)
{
$uri
=
$this
->
getGitRemoteFetchURI
(
$remote_name
)
;
return
(
$uri
!==
null
)
;
}
private
function
getGitRemoteFetchURI
(
$remote_name
)
{
return
$this
->
getGitRemoteURI
(
$remote_name
,
$for_push
=
false
)
;
}
private
function
getGitRemotePushURI
(
$remote_name
)
{
return
$this
->
getGitRemoteURI
(
$remote_name
,
$for_push
=
true
)
;
}
private
function
getGitRemoteURI
(
$remote_name
,
$for_push
)
{
$remote_uri
=
$this
->
loadGitRemoteURI
(
$remote_name
,
$for_push
)
;
if
(
$remote_uri
!==
null
)
{
$remote_uri
=
rtrim
(
$remote_uri
)
;
if
(
!
strlen
(
$remote_uri
)
)
{
$remote_uri
=
null
;
}
}
return
$remote_uri
;
}
private
function
loadGitRemoteURI
(
$remote_name
,
$for_push
)
{
// Try to identify the best URI for a given remote. This is complicated
// because remotes may have different "push" and "fetch" URIs, may
// rewrite URIs with "insteadOf" configuration, and different versions
// of Git support different URI resolution commands.
// Remotes may also have more than one URI of a given type, but we ignore
// those cases here.
// Start with "git remote get-url [--push]". This is the simplest and
// most accurate command, but was introduced most recently in Git's
// history.
$argv
=
array
(
)
;
if
(
$for_push
)
{
$argv
[
]
=
'--push'
;
}
list
(
$err
,
$stdout
)
=
$this
->
execManualLocal
(
'remote get-url %Ls -- %s'
,
$argv
,
$remote_name
)
;
if
(
!
$err
)
{
return
$stdout
;
}
// See T13481. If "git remote get-url [--push]" failed, it might be because
// the remote does not exist, but it might also be because the version of
// Git is too old to support "git remote get-url", which was introduced
// in Git 2.7 (circa late 2015).
$git_version
=
$this
->
getGitVersion
(
)
;
if
(
version_compare
(
$git_version
,
'2.7'
,
'>='
)
)
{
// This version of Git should support "git remote get-url --push", but
// the command failed, so conclude this is not a valid remote and thus
// there is no remote URI.
return
null
;
}
// If we arrive here, we're in a version of Git which is too old to
// support "git remote get-url [--push]". We're going to fall back to
// older and less accurate mechanisms for figuring out the remote URI.
// The first mechanism we try is "git ls-remote --get-url". This exists
// in Git 1.7.5 or newer. It only gives us the fetch URI, so this result
// will be incorrect if a remote has different fetch and push URIs.
// However, this is very rare, and this result is almost always correct.
// Note that some old versions of Git do not parse "--" in this command
// properly. We omit it since it doesn't seem like there's anything
// dangerous an attacker can do even if they can choose a remote name to
// intentionally cause an argument misparse.
// This will cause the command to behave incorrectly for remotes with
// names which are also valid flags, like "--quiet".
list
(
$err
,
$stdout
)
=
$this
->
execManualLocal
(
'ls-remote --get-url %s'
,
$remote_name
)
;
if
(
!
$err
)
{
// The "git ls-remote --get-url" command just echoes the remote name
// (like "origin") if no remote URI is found. Treat this like a failure.
$output_is_input
=
(
rtrim
(
$stdout
)
===
$remote_name
)
;
if
(
!
$output_is_input
)
{
return
$stdout
;
}
}
if
(
version_compare
(
$git_version
,
'1.7.5'
,
'>='
)
)
{
// This version of Git should support "git ls-remote --get-url", but
// the command failed (or echoed the input), so conclude the remote
// really does not exist.
return
null
;
}
// Fall back to the very old "git config -- remote.origin.url" command.
// This does not give us push URLs and does not resolve "insteadOf"
// aliases, but still works in the simplest (and most common) cases.
list
(
$err
,
$stdout
)
=
$this
->
execManualLocal
(
'config -- %s'
,
sprintf
(
'remote.%s.url'
,
$remote_name
)
)
;
if
(
!
$err
)
{
return
$stdout
;
}
return
null
;
}
protected
function
newCurrentCommitSymbol
(
)
{
return
'HEAD'
;
}
public
function
isGitLFSWorkingCopy
(
)
{
// We're going to run:
//
// $ git ls-files -z -- ':(attr:filter=lfs)'
//
// ...and exit as soon as it generates any field terminated with a "\0".
//
// If this command generates any such output, that means this working copy
// contains at least one LFS file, so it's an LFS working copy. If it
// exits with no error and no output, this is not an LFS working copy.
//
// If it exits with an error, we're in trouble.
$future
=
$this
->
buildLocalFuture
(
array
(
'ls-files -z -- %s'
,
':(attr:filter=lfs)'
,
)
)
;
$lfs_list
=
id
(
new
LinesOfALargeExecFuture
(
$future
)
)
->
setDelimiter
(
"\0"
)
;
try
{
foreach
(
$lfs_list
as
$lfs_file
)
{
// We have our answer, so we can throw the subprocess away.
$future
->
resolveKill
(
)
;
return
true
;
}
return
false
;
}
catch
(
CommandException
$ex
)
{
// This is probably an older version of Git. Continue below.
}
// In older versions of Git, the first command will fail with an error
// ("Invalid pathspec magic..."). See PHI1718.
//
// Some other tests we could use include:
//
// (1) Look for ".gitattributes" at the repository root. This approach is
// a rough approximation because ".gitattributes" may be global or in a
// subdirectory. See D21190.
//
// (2) Use "git check-attr" and pipe a bunch of files into it, roughly
// like this:
//
// $ git ls-files -z -- | git check-attr --stdin -z filter --
//
// However, the best version of this check I could come up with is fairly
// slow in even moderately large repositories (~200ms in a repository with
// 10K paths). See D21190.
//
// (3) Use "git lfs ls-files". This is even worse than piping "ls-files"
// to "check-attr" in PHP (~600ms in a repository with 10K paths).
//
// (4) Give up and just assume the repository isn't LFS. This is the
// current behavior.
return
false
;
}
protected
function
newLandEngine
(
)
{
return
new
ArcanistGitLandEngine
(
)
;
}
public
function
newLocalState
(
)
{
return
id
(
new
ArcanistGitLocalState
(
)
)
->
setRepositoryAPI
(
$this
)
;
}
public
function
readRawCommit
(
$hash
)
{
list
(
$stdout
)
=
$this
->
execxLocal
(
'cat-file commit -- %s'
,
$hash
)
;
return
ArcanistGitRawCommit
::
newFromRawBlob
(
$stdout
)
;
}
public
function
writeRawCommit
(
ArcanistGitRawCommit
$commit
)
{
$blob
=
$commit
->
getRawBlob
(
)
;
$future
=
$this
->
execFutureLocal
(
'hash-object -t commit --stdin -w'
)
;
$future
->
write
(
$blob
)
;
list
(
$stdout
)
=
$future
->
resolvex
(
)
;
return
trim
(
$stdout
)
;
}
}
File Metadata
Details
Attached
Mime Type
text/x-php
Expires
Sun, Jan 19, 12:57 (3 w, 4 d ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1124814
Default Alt Text
ArcanistGitAPI.php (53 KB)
Attached To
Mode
rARC Arcanist
Attached
Detach File
Event Timeline
Log In to Comment