Skip to content

Commit 580f79a

Browse files
committed
feat(git): mark the last pushed commit in history
The history table listed every commit alike, so nothing said which of them the remote already has, or whether the branch had ever been pushed at all. Draw a waterline where the remote's branch points. Commits above it carry the same orange left edge the gutter puts on a modified line, so "edge means not saved upstream yet" reads the same in the editor and here. The marker takes its colour from the state it describes: blue while there is nothing to push, orange once commits are waiting, so the run of orange above closes on an orange line and reads as one mark. A branch with no counterpart on the remote gets a "not pushed" banner instead, closed off at the initial commit once the whole history is on screen. Where the remote points comes from one `for-each-ref` reading the branch and the remote ref together — the branch row carries the upstream's name, the remote row the hash. A branch tracking some other ref needs a second call; nothing else does, and paging costs none, since the answer latches once the waterline is placed. File history is left alone: it lists only the commits touching one file, so the remote's head is usually not among them and every row would be wrongly called local.
1 parent 420d94f commit 580f79a

9 files changed

Lines changed: 349 additions & 19 deletions

File tree

‎src/extensions/default/Git/src/History.js‎

Lines changed: 109 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ define(function (require) {
66
FileUtils = brackets.getModule("file/FileUtils"),
77
LocalizationUtils = brackets.getModule("utils/LocalizationUtils"),
88
Strings = brackets.getModule("strings"),
9+
StringUtils = brackets.getModule("utils/StringUtils"),
910
Metrics = brackets.getModule("utils/Metrics"),
1011
NotificationUI = brackets.getModule("widgets/NotificationUI"),
1112
Mustache = brackets.getModule("thirdparty/mustache/mustache");
@@ -20,7 +21,8 @@ define(function (require) {
2021

2122
// Templates
2223
var gitPanelHistoryTemplate = require("text!templates/git-panel-history.html"),
23-
gitPanelHistoryCommitsTemplate = require("text!templates/git-panel-history-commits.html");
24+
gitPanelHistoryCommitsTemplate = require("text!templates/git-panel-history-commits.html"),
25+
gitPanelHistoryEndTemplate = require("text!templates/git-panel-history-end.html");
2426

2527
// Module variables
2628
let $gitPanel = $(null),
@@ -29,6 +31,14 @@ define(function (require) {
2931
commitCache = [],
3032
lastDocumentSeen = null;
3133

34+
// Where the branch stands against its remote, for the push waterline.
35+
// `remoteHead` is null when the branch has no counterpart on the remote at
36+
// all; `remoteHeadSeen` latches once the waterline has been placed, so the
37+
// pages loaded after it are known to be pushed without re-testing them.
38+
let remoteHead = null,
39+
remoteHeadSeen = false,
40+
unpushedCount = 0;
41+
3242
// must match the page size git log is invoked with in GitCli.getHistory
3343
const HISTORY_PAGE_SIZE = 100;
3444

@@ -100,12 +110,15 @@ define(function (require) {
100110
function _renderHistoryTable(commits, file) {
101111
// calculate some missing stuff like avatars
102112
commits = addAdditionalCommitInfo(commits);
113+
commits = markPushState(commits, file);
103114
commitCache = commitCache.concat(commits);
104115

105116
const templateData = {
106117
commits: commits,
107-
emptyMessage: file ? Strings.GIT_FILE_HISTORY_NOTHING_TO_SHOW : Strings.GIT_HISTORY_NOTHING_TO_SHOW,
108-
Strings: Strings
118+
// the banner only makes sense for a branch, not one file's history
119+
notPushed: !file && !remoteHead && commits.length > 0,
120+
Strings: Strings,
121+
emptyMessage: file ? Strings.GIT_FILE_HISTORY_NOTHING_TO_SHOW : Strings.GIT_HISTORY_NOTHING_TO_SHOW
109122
};
110123

111124
$tableContainer.find("#git-history-list").remove();
@@ -120,10 +133,30 @@ define(function (require) {
120133
if (commits.length < HISTORY_PAGE_SIZE) {
121134
// the full history is already here, so the last commit is the initial
122135
// one. with more pages the initial commit is marked by loadMoreHistory.
123-
$historyList.attr("x-finished", "true");
124-
$historyList
125-
.find("tr.history-commit:last-child")
126-
.attr("x-initial-commit", "true");
136+
markHistoryFinished();
137+
}
138+
}
139+
140+
/**
141+
* Note that the whole history is on screen.
142+
*
143+
* Marks the oldest commit so HistoryViewer knows it has nothing to diff
144+
* against, and, for a branch with no counterpart on the remote, closes the
145+
* unpushed run with a line — only here, because until the initial commit is
146+
* reached a closing line would claim a bottom the list does not have yet.
147+
*
148+
* Matches the last commit row with `.last()` rather than `:last-child`: the
149+
* waterline rows are siblings, so the oldest commit is not always the final
150+
* child of the table body.
151+
*/
152+
function markHistoryFinished() {
153+
$historyList.attr("x-finished", "true");
154+
const $lastCommit = $historyList.find("tr.history-commit").last();
155+
$lastCommit.attr("x-initial-commit", "true");
156+
if (!remoteHead && $lastCommit.length && !$historyList.find("tr.history-push-end").length) {
157+
$lastCommit.after(Mustache.render(gitPanelHistoryEndTemplate, {
158+
allLocalText: StringUtils.format(Strings.GIT_HISTORY_ALL_LOCAL, unpushedCount)
159+
}));
127160
}
128161
}
129162

@@ -134,14 +167,25 @@ define(function (require) {
134167
// clear cache
135168
commitCache = [];
136169

170+
// the waterline is recomputed per render: the remote may have moved
171+
remoteHead = null;
172+
remoteHeadSeen = false;
173+
unpushedCount = 0;
174+
137175
return Git.getCurrentBranchName().then(function (branchName) {
138-
// Get the history commits of the current branch
139-
var p = file ? Git.getFileHistory(file.relative, branchName) : Git.getHistory(branchName);
140-
return p.then(function (commits) {
141-
if (renderId === historyRenderId) {
142-
_renderHistoryTable(commits, file);
143-
}
144-
return true;
176+
return Git.getRemoteBranchHead(branchName).catch(function () {
177+
// a repo without remotes is normal, not an error to surface
178+
return null;
179+
}).then(function (head) {
180+
remoteHead = head;
181+
// Get the history commits of the current branch
182+
var p = file ? Git.getFileHistory(file.relative, branchName) : Git.getHistory(branchName);
183+
return p.then(function (commits) {
184+
if (renderId === historyRenderId) {
185+
_renderHistoryTable(commits, file);
186+
}
187+
return true;
188+
});
145189
});
146190
}).catch(function (err) {
147191
if (renderId !== historyRenderId) {
@@ -184,15 +228,13 @@ define(function (require) {
184228
return;
185229
}
186230
if (commits.length === 0) {
187-
$historyList.attr("x-finished", "true");
188231
// marks initial commit as first
189-
$historyList
190-
.find("tr.history-commit:last-child")
191-
.attr("x-initial-commit", "true");
232+
markHistoryFinished();
192233
return;
193234
}
194235

195236
commits = addAdditionalCommitInfo(commits);
237+
commits = markPushState(commits, file);
196238
commitCache = commitCache.concat(commits);
197239

198240
var templateData = {
@@ -241,6 +283,55 @@ define(function (require) {
241283
return commits;
242284
}
243285

286+
/**
287+
* Flag each commit against the remote, for the waterline the table draws.
288+
*
289+
* Commits arrive newest first, so everything listed before the remote's
290+
* head is still local: walk until that commit shows up, mark it, and leave
291+
* the rest alone — they are older than the remote tip and therefore pushed.
292+
* The latch carries the answer across pages, since every later page is
293+
* older than the one before it. With no counterpart on the remote nothing
294+
* has been pushed and every commit is flagged.
295+
*
296+
* File history is skipped entirely: it lists only the commits touching one
297+
* file, so the remote's head usually is not among them and every row would
298+
* be wrongly called local.
299+
*
300+
* @param {Array<Object>} commits - commits for this page, newest first
301+
* @param {?(Object|string)} file - whatever the caller holds for the file on
302+
* show; only its presence matters, the first page has the object and
303+
* later pages the relative path
304+
* @return {Array<Object>} the same commits, flagged in place
305+
*/
306+
function markPushState(commits, file) {
307+
if (file) {
308+
return commits;
309+
}
310+
_.forEach(commits, function (commit) {
311+
if (!remoteHead) {
312+
commit.unpushed = true;
313+
return;
314+
}
315+
if (remoteHeadSeen) {
316+
return;
317+
}
318+
if (commit.hash === remoteHead.hash) {
319+
commit.isRemoteHead = true;
320+
// the ref name is a git identifier, never translated
321+
commit.remoteRef = remoteHead.ref;
322+
commit.isAhead = unpushedCount > 0;
323+
commit.pushStateText = commit.isAhead
324+
? StringUtils.format(Strings.GIT_HISTORY_AHEAD, unpushedCount)
325+
: Strings.GIT_HISTORY_UP_TO_DATE;
326+
remoteHeadSeen = true;
327+
return;
328+
}
329+
commit.unpushed = true;
330+
unpushedCount++;
331+
});
332+
return commits;
333+
}
334+
244335
function getCurrentDocument() {
245336
if (HistoryViewer.isVisible()) {
246337
return lastDocumentSeen;

‎src/extensions/default/Git/src/git/Git.js‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ define(function (require, exports) {
8282
return GitCli.getHistory(branch, skip, file);
8383
}
8484

85+
function getRemoteBranchHead(branch) {
86+
return GitCli.getRemoteBranchHead(branch);
87+
}
88+
8589
function resetIndex() {
8690
return GitCli.reset();
8791
}
@@ -180,6 +184,7 @@ define(function (require, exports) {
180184
exports.getAllBranches = getAllBranches;
181185
exports.getHistory = getHistory;
182186
exports.getFileHistory = getFileHistory;
187+
exports.getRemoteBranchHead = getRemoteBranchHead;
183188
exports.resetIndex = resetIndex;
184189
exports.discardAllChanges = discardAllChanges;
185190
exports.getMergeInfo = getMergeInfo;

‎src/extensions/default/Git/src/git/GitCli.js‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,76 @@ define(function (require, exports) {
501501
});
502502
}
503503

504+
/**
505+
* Where the current branch's counterpart on the remote points right now.
506+
*
507+
* The configured upstream wins, but a branch pushed with an explicit refspec
508+
* often never gets one, so the default remote's branch of the same name
509+
* stands in — otherwise history would call a pushed branch local. Resolves
510+
* to null when neither ref is there, which is what a branch that has never
511+
* been pushed looks like.
512+
*
513+
* Usually one git call: a single `for-each-ref` reads the branch and that
514+
* default-remote ref together, giving the upstream's name and the hash in
515+
* the same pass. Only a branch tracking some other ref needs a second.
516+
*
517+
* @param {string} branch - name of the current branch
518+
* @return {Promise<?{ref: string, hash: string}>} null when there is no counterpart
519+
*/
520+
function getRemoteBranchHead(branch) {
521+
if (!branch) {
522+
return Promise.resolve(null);
523+
}
524+
const remotes = Preferences.get("defaultRemotes") || {};
525+
const remote = remotes[Preferences.get("currentGitRoot")];
526+
const guess = remote ? remote + "/" + branch : null;
527+
// The full refname disambiguates: a branch really can be called
528+
// "origin/ai", and then both rows would answer to the same short name.
529+
const localRef = "refs/heads/" + branch;
530+
const guessRef = guess ? "refs/remotes/" + guess : null;
531+
// "|" is legal in a ref name, so the fields are parted the way
532+
// getHistory above does it rather than on a character refs may contain.
533+
const separator = "_._";
534+
const args = [
535+
"for-each-ref",
536+
"--format=%(refname)" + separator + "%(upstream:short)" + separator + "%(objectname)",
537+
localRef
538+
];
539+
if (guessRef) { args.push(guessRef); }
540+
541+
// One pass over both refs: the branch row carries the upstream's name,
542+
// the remote row carries the hash. A ref that is not there prints
543+
// nothing rather than failing, so absence needs no special case.
544+
return git(args).then(function (stdout) {
545+
let upstream = null, guessHash = null;
546+
(stdout || "").split("\n").forEach(function (line) {
547+
const parts = line.trim().split(separator);
548+
if (parts.length < 3) { return; }
549+
if (parts[0] === localRef) {
550+
upstream = parts[1] || null;
551+
} else if (parts[0] === guessRef) {
552+
guessHash = parts[2] || null;
553+
}
554+
});
555+
if (upstream && upstream !== guess) {
556+
// Tracking something other than <remote>/<branch>, so its hash
557+
// was not in the pass above. rev-parse resolves it whatever it
558+
// is — a remote-tracking ref, or a local branch for `remote = .`
559+
return git(["rev-parse", "--verify", "--quiet", upstream])
560+
.then(function (out) {
561+
const hash = (out || "").trim();
562+
return hash ? { ref: upstream, hash: hash } : null;
563+
})
564+
.catch(function () { return null; });
565+
}
566+
// The upstream is the ref just read, or there is no upstream and the
567+
// branch was pushed to its own name; either way the hash is in hand.
568+
return guessHash ? { ref: guess, hash: guessHash } : null;
569+
}).catch(function () {
570+
return null;
571+
});
572+
}
573+
504574
// Get list of deleted files between two branches
505575
function getDeletedFiles(oldBranch, newBranch) {
506576
return git(["diff", "--no-ext-diff", "--name-status", oldBranch + ".." + newBranch])
@@ -1166,6 +1236,7 @@ define(function (require, exports) {
11661236
exports.setUpstreamBranch = setUpstreamBranch;
11671237
exports.getCurrentBranchName = getCurrentBranchName;
11681238
exports.getCurrentUpstreamBranch = getCurrentUpstreamBranch;
1239+
exports.getRemoteBranchHead = getRemoteBranchHead;
11691240
exports.getConfig = getConfig;
11701241
exports.setConfig = setConfig;
11711242
exports.getBranches = getBranches;

‎src/extensions/default/Git/styles/git-styles.less‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,95 @@
452452
width: 50px;
453453
}
454454
}
455+
// The push waterline: commits above it are not on the remote yet. They
456+
// carry the same orange edge the gutter puts on a modified line, so "edge
457+
// means not saved upstream yet" reads the same in the editor and here.
458+
// box-shadow rather than the gutter's border-left, which would widen the
459+
// cell and shift the whole column.
460+
tbody tr.history-commit.unpushed > td:first-child {
461+
box-shadow: inset 3px 0 0 @git-orange;
462+
.dark & {
463+
box-shadow: inset 3px 0 0 @dark-git-orange;
464+
}
465+
}
466+
tbody tr.history-push-line > td {
467+
width: auto;
468+
padding: 0;
469+
border: 0;
470+
background: none;
471+
.push-line-inner {
472+
display: flex;
473+
align-items: center;
474+
gap: 8px;
475+
padding: 3px 10px 3px 8px;
476+
user-select: none;
477+
}
478+
// Blue while there is nothing to push: the ref is just a landmark. The
479+
// `.ahead` rule below repaints it when commits are waiting, so the
480+
// marker states what is pending instead of always looking urgent.
481+
.push-line-ref {
482+
display: inline-flex;
483+
align-items: center;
484+
gap: 5px;
485+
flex: 0 0 auto;
486+
font-size: 10.5px;
487+
line-height: 1;
488+
font-weight: 600;
489+
white-space: nowrap;
490+
padding: 3px 7px;
491+
border-radius: 3px;
492+
color: @git-blue-text;
493+
border: 1px solid @git-blue-text;
494+
.dark & {
495+
color: @git-dark-blue-text;
496+
border-color: @git-dark-blue-text;
497+
}
498+
}
499+
.push-line-rule {
500+
flex: 1 1 auto;
501+
height: 0;
502+
border-top: 1px dashed @git-blue-text;
503+
opacity: 0.45;
504+
.dark & {
505+
border-top-color: @git-dark-blue-text;
506+
}
507+
}
508+
.push-line-note {
509+
flex: 0 0 auto;
510+
font-size: 10px;
511+
white-space: nowrap;
512+
opacity: 0.8;
513+
color: @git-blue-text;
514+
.dark & {
515+
color: @git-dark-blue-text;
516+
}
517+
}
518+
}
519+
// Commits are waiting: the orange run above closes on an orange line, so
520+
// the block and its boundary read as one mark rather than two.
521+
tbody tr.history-push-line.ahead > td {
522+
.push-line-ref {
523+
color: @git-orange-text;
524+
border-color: @git-orange;
525+
.dark & {
526+
color: @dark-git-orange;
527+
border-color: @dark-git-orange;
528+
}
529+
}
530+
.push-line-rule {
531+
border-top-color: @git-orange;
532+
opacity: 0.9;
533+
.dark & {
534+
border-top-color: @dark-git-orange;
535+
}
536+
}
537+
.push-line-note {
538+
color: @git-orange-text;
539+
.dark & {
540+
color: @dark-git-orange;
541+
}
542+
}
543+
}
455544
tbody tr.history-empty-message td {
456545
width: auto;
457546
padding: 15px;

0 commit comments

Comments
 (0)