diff --git a/src/extensions/default/Git/src/History.js b/src/extensions/default/Git/src/History.js index 52139655c6..91cf75ca63 100644 --- a/src/extensions/default/Git/src/History.js +++ b/src/extensions/default/Git/src/History.js @@ -6,6 +6,7 @@ define(function (require) { FileUtils = brackets.getModule("file/FileUtils"), LocalizationUtils = brackets.getModule("utils/LocalizationUtils"), Strings = brackets.getModule("strings"), + StringUtils = brackets.getModule("utils/StringUtils"), Metrics = brackets.getModule("utils/Metrics"), NotificationUI = brackets.getModule("widgets/NotificationUI"), Mustache = brackets.getModule("thirdparty/mustache/mustache"); @@ -20,7 +21,8 @@ define(function (require) { // Templates var gitPanelHistoryTemplate = require("text!templates/git-panel-history.html"), - gitPanelHistoryCommitsTemplate = require("text!templates/git-panel-history-commits.html"); + gitPanelHistoryCommitsTemplate = require("text!templates/git-panel-history-commits.html"), + gitPanelHistoryEndTemplate = require("text!templates/git-panel-history-end.html"); // Module variables let $gitPanel = $(null), @@ -29,6 +31,14 @@ define(function (require) { commitCache = [], lastDocumentSeen = null; + // Where the branch stands against its remote, for the push waterline. + // `remoteHead` is null when the branch has no counterpart on the remote at + // all; `remoteHeadSeen` latches once the waterline has been placed, so the + // pages loaded after it are known to be pushed without re-testing them. + let remoteHead = null, + remoteHeadSeen = false, + unpushedCount = 0; + // must match the page size git log is invoked with in GitCli.getHistory const HISTORY_PAGE_SIZE = 100; @@ -100,12 +110,15 @@ define(function (require) { function _renderHistoryTable(commits, file) { // calculate some missing stuff like avatars commits = addAdditionalCommitInfo(commits); + commits = markPushState(commits, file); commitCache = commitCache.concat(commits); const templateData = { commits: commits, - emptyMessage: file ? Strings.GIT_FILE_HISTORY_NOTHING_TO_SHOW : Strings.GIT_HISTORY_NOTHING_TO_SHOW, - Strings: Strings + // the banner only makes sense for a branch, not one file's history + notPushed: !file && !remoteHead && commits.length > 0, + Strings: Strings, + emptyMessage: file ? Strings.GIT_FILE_HISTORY_NOTHING_TO_SHOW : Strings.GIT_HISTORY_NOTHING_TO_SHOW }; $tableContainer.find("#git-history-list").remove(); @@ -120,10 +133,30 @@ define(function (require) { if (commits.length < HISTORY_PAGE_SIZE) { // the full history is already here, so the last commit is the initial // one. with more pages the initial commit is marked by loadMoreHistory. - $historyList.attr("x-finished", "true"); - $historyList - .find("tr.history-commit:last-child") - .attr("x-initial-commit", "true"); + markHistoryFinished(); + } + } + + /** + * Note that the whole history is on screen. + * + * Marks the oldest commit so HistoryViewer knows it has nothing to diff + * against, and, for a branch with no counterpart on the remote, closes the + * unpushed run with a line — only here, because until the initial commit is + * reached a closing line would claim a bottom the list does not have yet. + * + * Matches the last commit row with `.last()` rather than `:last-child`: the + * waterline rows are siblings, so the oldest commit is not always the final + * child of the table body. + */ + function markHistoryFinished() { + $historyList.attr("x-finished", "true"); + const $lastCommit = $historyList.find("tr.history-commit").last(); + $lastCommit.attr("x-initial-commit", "true"); + if (!remoteHead && $lastCommit.length && !$historyList.find("tr.history-push-end").length) { + $lastCommit.after(Mustache.render(gitPanelHistoryEndTemplate, { + allLocalText: StringUtils.format(Strings.GIT_HISTORY_ALL_LOCAL, unpushedCount) + })); } } @@ -134,14 +167,25 @@ define(function (require) { // clear cache commitCache = []; + // the waterline is recomputed per render: the remote may have moved + remoteHead = null; + remoteHeadSeen = false; + unpushedCount = 0; + return Git.getCurrentBranchName().then(function (branchName) { - // Get the history commits of the current branch - var p = file ? Git.getFileHistory(file.relative, branchName) : Git.getHistory(branchName); - return p.then(function (commits) { - if (renderId === historyRenderId) { - _renderHistoryTable(commits, file); - } - return true; + return Git.getRemoteBranchHead(branchName).catch(function () { + // a repo without remotes is normal, not an error to surface + return null; + }).then(function (head) { + remoteHead = head; + // Get the history commits of the current branch + var p = file ? Git.getFileHistory(file.relative, branchName) : Git.getHistory(branchName); + return p.then(function (commits) { + if (renderId === historyRenderId) { + _renderHistoryTable(commits, file); + } + return true; + }); }); }).catch(function (err) { if (renderId !== historyRenderId) { @@ -184,15 +228,13 @@ define(function (require) { return; } if (commits.length === 0) { - $historyList.attr("x-finished", "true"); // marks initial commit as first - $historyList - .find("tr.history-commit:last-child") - .attr("x-initial-commit", "true"); + markHistoryFinished(); return; } commits = addAdditionalCommitInfo(commits); + commits = markPushState(commits, file); commitCache = commitCache.concat(commits); var templateData = { @@ -241,6 +283,55 @@ define(function (require) { return commits; } + /** + * Flag each commit against the remote, for the waterline the table draws. + * + * Commits arrive newest first, so everything listed before the remote's + * head is still local: walk until that commit shows up, mark it, and leave + * the rest alone — they are older than the remote tip and therefore pushed. + * The latch carries the answer across pages, since every later page is + * older than the one before it. With no counterpart on the remote nothing + * has been pushed and every commit is flagged. + * + * File history is skipped entirely: it lists only the commits touching one + * file, so the remote's head usually is not among them and every row would + * be wrongly called local. + * + * @param {Array} commits - commits for this page, newest first + * @param {?(Object|string)} file - whatever the caller holds for the file on + * show; only its presence matters, the first page has the object and + * later pages the relative path + * @return {Array} the same commits, flagged in place + */ + function markPushState(commits, file) { + if (file) { + return commits; + } + _.forEach(commits, function (commit) { + if (!remoteHead) { + commit.unpushed = true; + return; + } + if (remoteHeadSeen) { + return; + } + if (commit.hash === remoteHead.hash) { + commit.isRemoteHead = true; + // the ref name is a git identifier, never translated + commit.remoteRef = remoteHead.ref; + commit.isAhead = unpushedCount > 0; + commit.pushStateText = commit.isAhead + ? StringUtils.format(Strings.GIT_HISTORY_AHEAD, unpushedCount) + : Strings.GIT_HISTORY_UP_TO_DATE; + remoteHeadSeen = true; + return; + } + commit.unpushed = true; + unpushedCount++; + }); + return commits; + } + function getCurrentDocument() { if (HistoryViewer.isVisible()) { return lastDocumentSeen; diff --git a/src/extensions/default/Git/src/Remotes.js b/src/extensions/default/Git/src/Remotes.js index 4721b81778..613c0a5cac 100644 --- a/src/extensions/default/Git/src/Remotes.js +++ b/src/extensions/default/Git/src/Remotes.js @@ -127,6 +127,16 @@ define(function (require) { clearRemotePicker(); } }).catch(function (err) { + // Opening a project without a repository still refreshes the panel, + // so `git remote -v` lands here reporting there is no repository. + // That is the project the user chose, not a fault, and a modal over + // the editor for it interrupts them for nothing — drop the picker + // and keep the reason in the log. + if (ErrorHandler.contains(err, "not a git repository")) { + clearRemotePicker(); + ErrorHandler.logError(err); + return; + } ErrorHandler.showError(err, Strings.ERROR_GETTING_REMOTES); }); } diff --git a/src/extensions/default/Git/src/git/Git.js b/src/extensions/default/Git/src/git/Git.js index f204b2ed2a..d7222aa125 100644 --- a/src/extensions/default/Git/src/git/Git.js +++ b/src/extensions/default/Git/src/git/Git.js @@ -82,6 +82,10 @@ define(function (require, exports) { return GitCli.getHistory(branch, skip, file); } + function getRemoteBranchHead(branch) { + return GitCli.getRemoteBranchHead(branch); + } + function resetIndex() { return GitCli.reset(); } @@ -180,6 +184,7 @@ define(function (require, exports) { exports.getAllBranches = getAllBranches; exports.getHistory = getHistory; exports.getFileHistory = getFileHistory; + exports.getRemoteBranchHead = getRemoteBranchHead; exports.resetIndex = resetIndex; exports.discardAllChanges = discardAllChanges; exports.getMergeInfo = getMergeInfo; diff --git a/src/extensions/default/Git/src/git/GitCli.js b/src/extensions/default/Git/src/git/GitCli.js index e62768219e..2d0ca8e1ef 100644 --- a/src/extensions/default/Git/src/git/GitCli.js +++ b/src/extensions/default/Git/src/git/GitCli.js @@ -501,6 +501,76 @@ define(function (require, exports) { }); } + /** + * Where the current branch's counterpart on the remote points right now. + * + * The configured upstream wins, but a branch pushed with an explicit refspec + * often never gets one, so the default remote's branch of the same name + * stands in — otherwise history would call a pushed branch local. Resolves + * to null when neither ref is there, which is what a branch that has never + * been pushed looks like. + * + * Usually one git call: a single `for-each-ref` reads the branch and that + * default-remote ref together, giving the upstream's name and the hash in + * the same pass. Only a branch tracking some other ref needs a second. + * + * @param {string} branch - name of the current branch + * @return {Promise} null when there is no counterpart + */ + function getRemoteBranchHead(branch) { + if (!branch) { + return Promise.resolve(null); + } + const remotes = Preferences.get("defaultRemotes") || {}; + const remote = remotes[Preferences.get("currentGitRoot")]; + const guess = remote ? remote + "/" + branch : null; + // The full refname disambiguates: a branch really can be called + // "origin/ai", and then both rows would answer to the same short name. + const localRef = "refs/heads/" + branch; + const guessRef = guess ? "refs/remotes/" + guess : null; + // "|" is legal in a ref name, so the fields are parted the way + // getHistory above does it rather than on a character refs may contain. + const separator = "_._"; + const args = [ + "for-each-ref", + "--format=%(refname)" + separator + "%(upstream:short)" + separator + "%(objectname)", + localRef + ]; + if (guessRef) { args.push(guessRef); } + + // One pass over both refs: the branch row carries the upstream's name, + // the remote row carries the hash. A ref that is not there prints + // nothing rather than failing, so absence needs no special case. + return git(args).then(function (stdout) { + let upstream = null, guessHash = null; + (stdout || "").split("\n").forEach(function (line) { + const parts = line.trim().split(separator); + if (parts.length < 3) { return; } + if (parts[0] === localRef) { + upstream = parts[1] || null; + } else if (parts[0] === guessRef) { + guessHash = parts[2] || null; + } + }); + if (upstream && upstream !== guess) { + // Tracking something other than /, so its hash + // was not in the pass above. rev-parse resolves it whatever it + // is — a remote-tracking ref, or a local branch for `remote = .` + return git(["rev-parse", "--verify", "--quiet", upstream]) + .then(function (out) { + const hash = (out || "").trim(); + return hash ? { ref: upstream, hash: hash } : null; + }) + .catch(function () { return null; }); + } + // The upstream is the ref just read, or there is no upstream and the + // branch was pushed to its own name; either way the hash is in hand. + return guessHash ? { ref: guess, hash: guessHash } : null; + }).catch(function () { + return null; + }); + } + // Get list of deleted files between two branches function getDeletedFiles(oldBranch, newBranch) { return git(["diff", "--no-ext-diff", "--name-status", oldBranch + ".." + newBranch]) @@ -1166,6 +1236,7 @@ define(function (require, exports) { exports.setUpstreamBranch = setUpstreamBranch; exports.getCurrentBranchName = getCurrentBranchName; exports.getCurrentUpstreamBranch = getCurrentUpstreamBranch; + exports.getRemoteBranchHead = getRemoteBranchHead; exports.getConfig = getConfig; exports.setConfig = setConfig; exports.getBranches = getBranches; diff --git a/src/extensions/default/Git/styles/git-styles.less b/src/extensions/default/Git/styles/git-styles.less index ad1d45d993..16a0c0194b 100644 --- a/src/extensions/default/Git/styles/git-styles.less +++ b/src/extensions/default/Git/styles/git-styles.less @@ -452,6 +452,95 @@ width: 50px; } } + // The push waterline: commits above it are not on the remote yet. They + // carry the same orange edge the gutter puts on a modified line, so "edge + // means not saved upstream yet" reads the same in the editor and here. + // box-shadow rather than the gutter's border-left, which would widen the + // cell and shift the whole column. + tbody tr.history-commit.unpushed > td:first-child { + box-shadow: inset 3px 0 0 @git-orange; + .dark & { + box-shadow: inset 3px 0 0 @dark-git-orange; + } + } + tbody tr.history-push-line > td { + width: auto; + padding: 0; + border: 0; + background: none; + .push-line-inner { + display: flex; + align-items: center; + gap: 8px; + padding: 3px 10px 3px 8px; + user-select: none; + } + // Blue while there is nothing to push: the ref is just a landmark. The + // `.ahead` rule below repaints it when commits are waiting, so the + // marker states what is pending instead of always looking urgent. + .push-line-ref { + display: inline-flex; + align-items: center; + gap: 5px; + flex: 0 0 auto; + font-size: 10.5px; + line-height: 1; + font-weight: 600; + white-space: nowrap; + padding: 3px 7px; + border-radius: 3px; + color: @git-blue-text; + border: 1px solid @git-blue-text; + .dark & { + color: @git-dark-blue-text; + border-color: @git-dark-blue-text; + } + } + .push-line-rule { + flex: 1 1 auto; + height: 0; + border-top: 1px dashed @git-blue-text; + opacity: 0.45; + .dark & { + border-top-color: @git-dark-blue-text; + } + } + .push-line-note { + flex: 0 0 auto; + font-size: 10px; + white-space: nowrap; + opacity: 0.8; + color: @git-blue-text; + .dark & { + color: @git-dark-blue-text; + } + } + } + // Commits are waiting: the orange run above closes on an orange line, so + // the block and its boundary read as one mark rather than two. + tbody tr.history-push-line.ahead > td { + .push-line-ref { + color: @git-orange-text; + border-color: @git-orange; + .dark & { + color: @dark-git-orange; + border-color: @dark-git-orange; + } + } + .push-line-rule { + border-top-color: @git-orange; + opacity: 0.9; + .dark & { + border-top-color: @dark-git-orange; + } + } + .push-line-note { + color: @git-orange-text; + .dark & { + color: @dark-git-orange; + } + } + } tbody tr.history-empty-message td { width: auto; padding: 15px; diff --git a/src/extensions/default/Git/templates/git-panel-history-commits.html b/src/extensions/default/Git/templates/git-panel-history-commits.html index 574fe3e98f..d33c4274e1 100644 --- a/src/extensions/default/Git/templates/git-panel-history-commits.html +++ b/src/extensions/default/Git/templates/git-panel-history-commits.html @@ -1,5 +1,16 @@ {{#commits}} - +{{#isRemoteHead}} + + +
+ {{remoteRef}} + + {{pushStateText}} +
+ + +{{/isRemoteHead}} +
{{avatarLetter}} diff --git a/src/extensions/default/Git/templates/git-panel-history-end.html b/src/extensions/default/Git/templates/git-panel-history-end.html new file mode 100644 index 0000000000..86ae76d370 --- /dev/null +++ b/src/extensions/default/Git/templates/git-panel-history-end.html @@ -0,0 +1,8 @@ + + +
+ + {{allLocalText}} +
+ + diff --git a/src/extensions/default/Git/templates/git-panel-history.html b/src/extensions/default/Git/templates/git-panel-history.html index 763af2c7da..6cb7351723 100644 --- a/src/extensions/default/Git/templates/git-panel-history.html +++ b/src/extensions/default/Git/templates/git-panel-history.html @@ -1,5 +1,16 @@ + {{#notPushed}} + + + + {{/notPushed}} {{> commits}} {{^commits}} diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 770212a6db..5aeb05bf56 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -2340,6 +2340,11 @@ define({ "GIT_NOT_REPO_MESSAGE": "This project is not a Git repository. Click the {0} button to create a new repository here, or {1} to download an existing one.", "GIT_HISTORY_NOTHING_TO_SHOW": "No commits yet. Committed changes will show up here.", "GIT_FILE_HISTORY_NOTHING_TO_SHOW": "There is no commit history for this file yet.", + "GIT_HISTORY_AHEAD": "{0} ahead", + "GIT_HISTORY_UP_TO_DATE": "up to date", + "GIT_HISTORY_NOT_PUSHED": "not pushed", + "GIT_HISTORY_NO_TRACKING": "no tracking branch", + "GIT_HISTORY_ALL_LOCAL": "{0} commits local", "GIT_FILE_HISTORY_OPEN_A_FILE": "Open a file in the editor to see its commit history.", "GIT_FILE_HISTORY_NOT_IN_REPO": "The file is not part of this Git repository.", "UNDO_CHANGES": "Discard changes", diff --git a/test/spec/Extn-Git-integ-test.js b/test/spec/Extn-Git-integ-test.js index 85e91e7201..fa00051568 100644 --- a/test/spec/Extn-Git-integ-test.js +++ b/test/spec/Extn-Git-integ-test.js @@ -28,6 +28,7 @@ define(function (require, exports, module) { } let $, __PR, testWindow, ExtensionLoader, Menus, Commands, CommandManager, EditorManager, MainViewManager, + Strings = require("strings"), SpecRunnerUtils = require("spec/SpecRunnerUtils"), nonGitReadOnlyTestFolder = SpecRunnerUtils.getTestPath("/spec/LowLevelFileIO-test-files"); @@ -357,6 +358,44 @@ define(function (require, exports, module) { _verifyHistoryCommits(); }); + it("should mark every commit local while the repo has no remote", async () => { + // This repo was created by `git init` earlier in the suite and + // never gained a remote, so nothing in it can have been pushed. + // The previous spec leaves file history up and the waterline is + // branch-only, so switch back to the branch's history first. + await __PR.execCommand(Commands.CMD_GIT_TOGGLE_PANEL); + await waitForHistoryVisible(false); + await __PR.execCommand(Commands.CMD_GIT_HISTORY_GLOBAL); + await waitForHistoryVisible(true); + + const $historyList = $gitPanel.find("#git-history-list"); + expect($historyList.data("file")).toBeFalsy(); + const $commits = $historyList.find("tr.history-commit"); + expect($commits.length).toBeGreaterThanOrEqual(2); + // no remote to compare against, so no commit is behind a waterline + expect($historyList.find("tr.history-commit.unpushed").length).toBe($commits.length); + + const $banner = $historyList.find("tr.history-push-line.not-pushed"); + expect($banner.length).toBe(1); + expect($banner.find(".push-line-ref").text().trim()).toBe(Strings.GIT_HISTORY_NOT_PUSHED); + expect($banner.find(".push-line-note").text().trim()).toBe(Strings.GIT_HISTORY_NO_TRACKING); + + // Both commits fit in one page, so the list is already finished + // and closes the local run off at the initial commit. + expect($historyList.find("tr.history-push-end").length).toBe(1); + expect($historyList.attr("x-finished")).toBe("true"); + // the waterline rows must not steal the initial-commit marker + expect($historyList.find("tr[x-initial-commit='true']").hasClass("history-commit")).toBeTrue(); + + // hand the panel back showing file history, the way this spec + // found it — the specs after this one run in order and start + // from that state. + await __PR.execCommand(Commands.CMD_GIT_TOGGLE_PANEL); + await waitForHistoryVisible(false); + await __PR.execCommand(Commands.CMD_GIT_HISTORY_FILE); + await waitForHistoryVisible(true); + }); + async function waitForHistoryViewerVisible(visible) { await awaitsFor(() => { return $("#history-viewer").is(":visible") === visible;
+
+ {{Strings.GIT_HISTORY_NOT_PUSHED}} + + {{Strings.GIT_HISTORY_NO_TRACKING}} +
+
{{emptyMessage}}