diff --git a/src/land/engine/ArcanistMercurialLandEngine.php b/src/land/engine/ArcanistMercurialLandEngine.php --- a/src/land/engine/ArcanistMercurialLandEngine.php +++ b/src/land/engine/ArcanistMercurialLandEngine.php @@ -803,8 +803,8 @@ // descendants and the min commit has no ancestors. The min/max terms are // used in a topological sense as chronological terms for commits can be // misleading or incorrect in certain situations. - $max_commit = last($commits)->getHash(); $min_commit = head($commits)->getHash(); + $max_commit = last($commits)->getHash(); $revision_ref = $set->getRevisionRef(); $commit_message = $revision_ref->getCommitMessage(); diff --git a/src/repository/api/ArcanistMercurialAPI.php b/src/repository/api/ArcanistMercurialAPI.php --- a/src/repository/api/ArcanistMercurialAPI.php +++ b/src/repository/api/ArcanistMercurialAPI.php @@ -658,37 +658,120 @@ public function doCommit($message) { $tmp_file = new TempFile(); Filesystem::writeFile($tmp_file, $message); - $this->execxLocal('commit -l %s', $tmp_file); + $this->execxLocal('commit --logfile %s', $tmp_file); $this->reloadWorkingCopy(); } public function amendCommit($message = null) { + $path_statuses = $this->buildUncommittedStatus(); + if ($message === null) { + if (empty($path_statuses)) { + // If there are no changes to the working directory and the message is + // not being changed then there's nothing to amend. + return; + } + $message = $this->getCommitMessage('.'); } $tmp_file = new TempFile(); Filesystem::writeFile($tmp_file, $message); - try { - $this->execxLocal( - 'commit --amend -l %s', - $tmp_file); - } catch (CommandException $ex) { - if (preg_match('/nothing changed/', $ex->getStdout())) { - // NOTE: Mercurial considers it an error to make a no-op amend. Although - // we generally defer to the underlying VCS to dictate behavior, this - // one seems a little goofy, and we use amend as part of various - // workflows under the assumption that no-op amends are fine. If this - // amend failed because it's a no-op, just continue. - } else { + if ($this->getMercurialFeature('evolve')) { + $this->execxLocal('amend --logfile %s --', $tmp_file); + try { + $this->execxLocal('evolve --all --'); + } catch (CommandException $ex) { + $this->execxLocal('evolve --abort --'); throw $ex; } + $this->reloadWorkingCopy(); + return; + } + + // Get the child nodes of the current changeset. + list($children) = $this->execxLocal( + 'log --template %s --rev %s --', + '{node} ', + 'children(.)'); + $child_nodes = array_filter(explode(' ', $children)); + + // For a head commit we can simply use `commit --amend` for both new commit + // message and amending changes from the working directory. + if (empty($child_nodes)) { + try { + $this->execxLocal('commit --amend --logfile %s --', $tmp_file); + } catch (CommandException $ex) { + if (preg_match('/nothing changed/', $ex->getStdout())) { + // NOTE: Mercurial considers it an error to make a no-op amend. + // Although we generally defer to the underlying VCS to dictate + // behavior, this one seems a little goofy, and we use amend as part + // of various workflows under the assumption that no-op amends are + // fine. If this amend failed because it's a no-op, just continue. + } else { + throw $ex; + } + } + } else { + $this->amendNonHeadCommit($child_nodes, $tmp_file); } $this->reloadWorkingCopy(); } + /** + * Amends a non-head commit with a new message and file changes. This + * strategy is for Mercurial repositories without the evolve extension. + * + * 1. Run 'arc-amend' which uses Mercurial internals to amend the current + * commit with updated message/file-changes. It results in a new commit + * from the right parent + * 2. For each branch from the original commit, rebase onto the new commit, + * removing the original branch. Note that there is potential for this to + * cause a conflict but this is something the user has to address. + * 3. Strip the original commit. + * + * @param array The list of child changesets off the original commit. + * @param file The file containing the new commit message. + */ + private function amendNonHeadCommit($child_nodes, $tmp_file) { + list($current) = $this->execxLocal( + 'log --template %s --rev . --', + '{node}'); + + $argv = array(); + foreach ($this->getMercurialExtensionArguments() as $arg) { + $argv[] = $arg; + } + $argv[] = 'arc-amend'; + $argv[] = '--logfile'; + $argv[] = $tmp_file; + $this->execxLocal('%Ls', $argv); + + list($new_commit) = $this->execxLocal( + 'log --rev tip --template %s --', + '{node}'); + + try { + foreach ($child_nodes as $child) { + // descendants(rev) will also include rev itself which is why this + // can't use a single rebase of descendants($current). + $revset = hgsprintf('descendants(%s)', $child); + $this->execxLocal( + 'rebase --dest %s --rev %s --', + $new_commit, + $revset); + } + } catch (CommandException $ex) { + $this->execxLocal('rebase --abort --'); + throw $ex; + } + + $this->execxLocal('--config extensions.strip= strip --rev %s --', + $current); + } + public function getCommitSummary($commit) { if ($commit == 'null') { return pht('(The Empty Void)'); diff --git a/src/repository/state/ArcanistGitLocalState.php b/src/repository/state/ArcanistGitLocalState.php --- a/src/repository/state/ArcanistGitLocalState.php +++ b/src/repository/state/ArcanistGitLocalState.php @@ -7,6 +7,8 @@ private $localRef; private $localPath; + private $stashStack = array(); + public function getLocalRef() { return $this->localRef; } @@ -130,6 +132,15 @@ // no error, and no effect if the working copy contains only untracked // files. For now, accept mutations to the stash list. + // Generate a random stash reference and place on the stash stack, so it + // can be verified when restoring stashes that it happens in appropriate + // order. + $stash_ref = sprintf( + 'arc-%s', + Filesystem::readRandomCharacters(12)); + + $this->stashStack[] = $stash_ref; + $api->execxLocal('stash push --include-untracked --'); $log = $this->getWorkflow()->getLogEngine(); @@ -137,10 +148,18 @@ pht('SAVE STASH'), pht('Saved uncommitted changes from working copy.')); - return true; + return $stash_ref; } protected function restoreStash($stash_ref) { + $current_stash_ref = array_pop($this->stashStack); + if ($stash_ref !== $current_stash_ref) { + throw new Exception(pht( + 'Unexpected stash reference for restoring stash: %s, but expected: %s', + $stash_ref, + $current_stash_ref)); + } + $api = $this->getRepositoryAPI(); $log = $this->getWorkflow()->getLogEngine(); diff --git a/src/repository/state/ArcanistRepositoryLocalState.php b/src/repository/state/ArcanistRepositoryLocalState.php --- a/src/repository/state/ArcanistRepositoryLocalState.php +++ b/src/repository/state/ArcanistRepositoryLocalState.php @@ -192,10 +192,28 @@ return false; } + /** + * Stash uncommitted changes temporarily. Use {@method:restoreStash()} to + * bring these changes back. + * + * Note that saving and restoring changes may not behave as expected if used + * in a non-stack manner, i.e. proper use involves only restoring stashes in + * the reverse order they were saved. + * + * @return wild A reference object that refers to the changes which were + * saved. When restoring changes this should be passed to + * {@method:restoreStash()}. + */ protected function saveStash() { throw new PhutilMethodNotImplementedException(); } + /** + * Restores changes that were previously stashed by {@method:saveStash()}. + * + * @param wild A reference object referring to which previously stashed + * changes to restore, from invoking {@method:saveStash()}. + */ protected function restoreStash($ref) { throw new PhutilMethodNotImplementedException(); } diff --git a/support/hg/arc-hg.py b/support/hg/arc-hg.py --- a/support/hg/arc-hg.py +++ b/support/hg/arc-hg.py @@ -21,18 +21,118 @@ hg, i18n, node, - registrar, ) _ = i18n._ cmdtable = {} -command = registrar.command(cmdtable) + +# Older veresions of Mercurial (~4.7) moved the command function and the +# remoteopts object to different modules. Using try/except here to attempt +# allowing this module to load properly, despite whether individual commands +# will work properly on older versions of Mercurial or not. +# https://phab.mercurial-scm.org/rHG46ba2cdda476ac53a8a8f50e4d9435d88267db60 +# https://phab.mercurial-scm.org/rHG04baab18d60a5c833ab3190506147e01b3c6d12c +try: + from mercurial import registrar + command = registrar.command(cmdtable) +except: + command = cmdutil.command(cmdtable) + +try: + if "remoteopts" in cmdutil: + remoteopts = cmdutil.remoteopts +except: + from mercurial import commands + remoteopts = commands.remoteopts + +@command( + b'arc-amend', + [ + (b'l', + b'logfile', + b'', + _(b'read commit message from file'), + _(b'FILE')), + (b'm', + b'message', + b'', + _(b'use text as commit message'), + _(b'TEXT')), + (b'u', + b'user', + b'', + _(b'record the specified user as committer'), + _(b'USER')), + (b'd', + b'date', + b'', + _(b'record the specified date as commit date'), + _(b'DATE')), + (b'A', + b'addremove', + False, + _(b'mark new/missing files as added/removed before committing')), + (b'n', + b'note', + b'', + _(b'store a note on amend'), + _(b'TEXT')), + ], + _(b'[OPTION]')) +def amend(ui, repo, source=None, **opts): + """amend + + Uses Mercurial internal API to amend changes to a non-head commit. + + (This is an Arcanist extension to Mercurial.) + + Returns 0 if amending succeeds, 1 otherwise. + """ + + # The option keys seem to come in as 'str' type but the cmdutil.amend() code + # expects them as binary. To account for both Python 2 and Python 3 + # compatibility, insert the value under both 'str' and binary type. + newopts = {} + for key in opts: + val = opts.get(key) + newopts[key] = val + if isinstance(key, str): + newkey = key.encode('UTF-8') + newopts[newkey] = val + + orig = repo[b'.'] + extra = {} + pats = [] + cmdutil.amend(ui, repo, orig, extra, pats, newopts) + + """ + # This will allow running amend on older versions of Mercurial, ~3.5, however + # the behavior on those versions will squash child commits of the working + # directory into the amended commit which is undesired. + try: + cmdutil.amend(ui, repo, orig, extra, pats, newopts) + except: + def commitfunc(ui, repo, message, match, opts): + return repo.commit( + message, + opts.get('user') or orig.user(), + opts.get('date') or orig.date(), + match, + extra=extra) + cmdutil.amend(ui, repo, commitfunc, orig, extra, pats, newopts) + """ + + return 0 @command( b'arc-ls-markers', - [(b'', b'output', b'', - _(b'file to output refs to'), _(b'FILE')), - ] + cmdutil.remoteopts, + [ + (b'', + b'output', + b'', + _(b'file to output refs to'), + _(b'FILE')), + ] + remoteopts, _(b'[--output FILENAME] [SOURCE]')) def lsmarkers(ui, repo, source=None, **opts): """list markers