diff --git a/src/internationalization/ArcanistUSEnglishTranslation.php b/src/internationalization/ArcanistUSEnglishTranslation.php --- a/src/internationalization/ArcanistUSEnglishTranslation.php +++ b/src/internationalization/ArcanistUSEnglishTranslation.php @@ -68,6 +68,16 @@ 'Ignore this untracked file and continue?', 'Ignore these untracked files and continue?', ), + + '%s submodule(s) have uncommitted or untracked changes:' => array( + 'A submodule has uncommitted or untracked changes:', + 'Submodules have uncommitted or untracked changes:', + ), + + 'Ignore the changes to these %s submodule(s) and continue?' => array( + 'Ignore the change to this submodule and continue?', + 'Ignore the changes to these submodules and continue?', + ), ); } diff --git a/src/repository/api/ArcanistGitAPI.php b/src/repository/api/ArcanistGitAPI.php --- a/src/repository/api/ArcanistGitAPI.php +++ b/src/repository/api/ArcanistGitAPI.php @@ -672,7 +672,7 @@ $result = new PhutilArrayWithDefaultValue(); list($stdout) = $uncommitted_future->resolvex(); - $uncommitted_files = $this->parseGitStatus($stdout); + $uncommitted_files = $this->parseGitRawDiff($stdout); foreach ($uncommitted_files as $path => $mask) { $result[$path] |= ($mask | self::FLAG_UNCOMMITTED); } @@ -704,7 +704,7 @@ $this->getDiffBaseOptions(), $this->getBaseCommit()); - return $this->parseGitStatus($stdout); + return $this->parseGitRawDiff($stdout); } public function getGitConfig($key, $default = null) { @@ -759,7 +759,7 @@ return $this; } - private function parseGitStatus($status, $full = false) { + private function parseGitRawDiff($status, $full = false) { static $flags = array( 'A' => self::FLAG_ADDED, 'M' => self::FLAG_MODIFIED, @@ -777,17 +777,51 @@ $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]; - foreach ($flags as $key => $bits) { - if ($flag == $key) { - $mask |= $bits; - } + + $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]; } + if ($full) { $files[$file] = array( 'mask' => $mask, - 'ref' => rtrim($line[3], '.'), + 'ref' => $new_hash, ); } else { $files[$file] = $mask; @@ -807,7 +841,7 @@ list($stdout) = $this->execxLocal( 'diff --raw %s', $since_commit); - return $this->parseGitStatus($stdout); + return $this->parseGitRawDiff($stdout); } public function getBlame($path) { diff --git a/src/repository/api/ArcanistRepositoryAPI.php b/src/repository/api/ArcanistRepositoryAPI.php --- a/src/repository/api/ArcanistRepositoryAPI.php +++ b/src/repository/api/ArcanistRepositoryAPI.php @@ -15,6 +15,9 @@ const FLAG_MISSING = 32; const FLAG_UNSTAGED = 64; const FLAG_UNCOMMITTED = 128; + + // Occurs in SVN when you have uncommitted changes to a modified external, + // or in Git when you have uncommitted or untracked changes in a submodule. const FLAG_EXTERNALS = 256; // Occurs in SVN when you replace a file with a directory without telling @@ -195,6 +198,14 @@ /** * @task status */ + final public function getDirtyExternalChanges() { + return $this->getUncommittedPathsWithMask(self::FLAG_EXTERNALS); + } + + + /** + * @task status + */ private function getUncommittedPathsWithMask($mask) { $match = array(); foreach ($this->getUncommittedStatus() as $path => $flags) { diff --git a/src/workflow/ArcanistWorkflow.php b/src/workflow/ArcanistWorkflow.php --- a/src/workflow/ArcanistWorkflow.php +++ b/src/workflow/ArcanistWorkflow.php @@ -893,11 +893,47 @@ implode("\n ", $missing))); } + $externals = $api->getDirtyExternalChanges(); + + // TODO: This state can exist in Subversion, but it is currently handled + // elsewhere. It should probably be handled here, eventually. + if ($api instanceof ArcanistSubversionAPI) { + $externals = array(); + } + + if ($externals) { + $message = pht( + '%s submodule(s) have uncommitted or untracked changes:', + new PhutilNumber(count($externals))); + + $prompt = pht( + 'Ignore the changes to these %s submodule(s) and continue?', + new PhutilNumber(count($externals))); + + $list = id(new PhutilConsoleList()) + ->setWrap(false) + ->addItems($externals); + + id(new PhutilConsoleBlock()) + ->addParagraph($message) + ->addList($list) + ->draw(); + + $ok = phutil_console_confirm($prompt, $default_no = false); + if (!$ok) { + throw new ArcanistUserAbortException(); + } + } + $uncommitted = $api->getUncommittedChanges(); $unstaged = $api->getUnstagedChanges(); + // We already dealt with externals. + $unstaged = array_diff($unstaged, $externals); + // We only want files which are purely uncommitted. $uncommitted = array_diff($uncommitted, $unstaged); + $uncommitted = array_diff($uncommitted, $externals); $untracked = $api->getUntrackedChanges(); if (!$this->shouldRequireCleanUntrackedFiles()) {