Fixed the autocomplete crash, root cause: getDocumentSymbols() accessed mDocs from a worker thread while document lifecycle callbacks could concurrently modify the same unordered_set, corrupting its internal state.

Added a synthetic “Working Tree / Index” first row whenever the selected repository has local or staged changes.
Displays separate changed and staged counts.
Updates dynamically from normal status refreshes without reloading commit history.
Selecting it opens the shared details view in “Uncommitted changes” mode.
Shows tracked changes from HEAD through the working tree, plus untracked-file patches.
Supports both sidebar and detached History views.
The History shortcut focuses working changes when present, otherwise the newest commit.
Added the History button beside Refresh.
Added consistent 2dp spacing between the ref dropdown and both buttons.
Added translations for the new labels.
This commit is contained in:
Martín Lucas Golini
2026-08-29 03:12:27 -03:00
parent 61d9d3c7bc
commit b3d72b5acf
14 changed files with 412 additions and 57 deletions
@@ -747,8 +747,11 @@ void AutoCompletePlugin::onRegister( UICodeEditor* editor ) {
std::vector<Uint32> listeners;
listeners.push_back( editor->on( Event::OnDocumentLoaded, [this, editor]( const Event* ) {
mDirty = true;
mDocs.insert( editor->getDocumentRef().get() );
mEditorDocs[editor] = editor->getDocumentRef().get();
{
Lock l( mDocMutex );
mDocs.insert( editor->getDocumentRef().get() );
mEditorDocs[editor] = editor->getDocumentRef().get();
}
tryRequestCapabilities( editor );
} ) );
@@ -2587,8 +2590,11 @@ AutoCompletePlugin::getDocumentSymbols( const std::shared_ptr<TextDocument>& doc
} ) )
symbols.push_back( std::move( matchStr ) );
}
if ( mShuttingDown || mDocs.find( doc ) == mDocs.end() )
break;
{
Lock l( mDocMutex );
if ( mShuttingDown || mDocs.find( doc ) == mDocs.end() )
break;
}
}
return symbols;
}
+58
View File
@@ -286,6 +286,64 @@ Git::CommitFiles Git::commitFiles( const Commit& commit, const std::string& proj
return result;
}
Git::CommitFiles Git::workingTreeFiles( const std::string& projectDir ) {
CommitFiles result;
Status current = status( false, projectDir );
for ( const auto& [_, files] : current.files ) {
for ( const auto& file : files ) {
auto found = std::find_if(
result.files.begin(), result.files.end(),
[&file]( const CommitFile& item ) { return item.path == file.file; } );
if ( found == result.files.end() ) {
CommitFile item;
item.path = file.file;
item.status = std::string( 1, static_cast<char>( file.report.symbol ) );
item.inserts = file.inserts;
item.deletes = file.deletes;
item.isBinary = file.isBinary;
result.files.emplace_back( std::move( item ) );
} else {
found->inserts += file.inserts;
found->deletes += file.deletes;
found->isBinary |= file.isBinary;
}
}
}
result.returnCode = git( { "diff", "--no-ext-diff", "--no-color", "-M", "HEAD", "--" },
projectDir, result.patch );
if ( result.fail() ) {
result.patch.clear();
result.returnCode =
git( { "diff", "--no-ext-diff", "--no-color", "-M", "--" }, projectDir, result.patch );
}
if ( result.fail() ) {
result.result = std::move( result.patch );
return result;
}
for ( const auto& file : result.files ) {
auto statusFile =
std::find_if( current.files.begin(), current.files.end(), [&file]( const auto& repo ) {
return std::any_of( repo.second.begin(), repo.second.end(),
[&]( const DiffFile& item ) {
return item.file == file.path &&
item.report.type == GitStatusType::Untracked;
} );
} );
if ( statusFile == current.files.end() )
continue;
auto patch = diffUntracked( file.path, projectDir );
if ( patch.success() ) {
if ( !result.patch.empty() && result.patch.back() != '\n' )
result.patch += '\n';
result.patch += patch.result;
}
}
result.result.clear();
return result;
}
Git::Result Git::commitDiff( const Commit& commit, const CommitFile& file,
const std::string& projectDir ) const {
Result result;
+2
View File
@@ -468,6 +468,8 @@ class Git {
CommitFiles commitFiles( const Commit& commit, const std::string& projectDir = "" ) const;
CommitFiles workingTreeFiles( const std::string& projectDir = "" );
Result commitDiff( const Commit& commit, const CommitFile& file,
const std::string& projectDir = "" ) const;
@@ -82,9 +82,11 @@ Variant GitHistoryModel::data( const ModelIndex& index, ModelRole role ) const {
if ( !item )
return {};
if ( role == ModelRole::Class ) {
if ( item->type == NodeType::WorkingTree )
return Variant( "git_history_working_tree" );
if ( item->type == NodeType::LoadMore || item->type == NodeType::Error )
return Variant( "git_history_action" );
if ( item->type != NodeType::Commit )
if ( item->type != NodeType::Commit && item->type != NodeType::WorkingTree )
return Variant( "git_history_secondary" );
if ( item->commit.isMerge() )
return Variant( "git_history_merge" );
@@ -99,7 +101,7 @@ Variant GitHistoryModel::data( const ModelIndex& index, ModelRole role ) const {
}
if ( role != ModelRole::Display )
return {};
if ( item->type != NodeType::Commit )
if ( item->type != NodeType::Commit && item->type != NodeType::WorkingTree )
return index.column() == Subject ? Variant( &item->message ) : Variant( "" );
switch ( index.column() ) {
case Subject:
@@ -138,6 +140,29 @@ GitHistoryModel::commitNode( Git::Commit commit, Node* parent,
return item;
}
std::unique_ptr<GitHistoryModel::Node>
GitHistoryModel::workingTreeNode( const Git::Status& status, const std::string& repoName ) const {
auto repo = status.files.find( repoName );
if ( repo == status.files.end() || repo->second.empty() )
return {};
size_t changed = 0;
size_t staged = 0;
for ( const auto& file : repo->second ) {
if ( file.report.type == Git::GitStatusType::Staged )
++staged;
else
++changed;
}
auto item = std::make_unique<Node>();
item->type = NodeType::WorkingTree;
item->subject = mPlugin->i18n( "git_working_tree_index", "Working Tree / Index" );
item->message = String::format(
mPlugin->i18n( "git_working_tree_summary", "%zu changed, %zu staged" ).toUtf8(), changed,
staged );
item->tooltip = item->subject + String{ "\n" } + item->message;
return item;
}
void GitHistoryModel::fillPage( Nodes& nodes, Node* parent, Git::HistoryPage page,
const Git::HistoryQuery& query ) {
for ( auto& commit : page.commits )
@@ -179,8 +204,11 @@ void GitHistoryModel::setRootLoading() {
invalidate();
}
void GitHistoryModel::setRootPage( Git::HistoryPage page, const Git::HistoryQuery& query ) {
void GitHistoryModel::setRootPage( Git::HistoryPage page, const Git::HistoryQuery& query,
const Git::Status& status, const std::string& repoName ) {
mRoots.clear();
if ( auto item = workingTreeNode( status, repoName ) )
mRoots.emplace_back( std::move( item ) );
fillPage( mRoots, nullptr, std::move( page ), query );
if ( mRoots.empty() ) {
auto item = std::make_unique<Node>();
@@ -191,6 +219,25 @@ void GitHistoryModel::setRootPage( Git::HistoryPage page, const Git::HistoryQuer
invalidate();
}
void GitHistoryModel::setWorkingTreeStatus( const Git::Status& status,
const std::string& repoName ) {
auto item = workingTreeNode( status, repoName );
const bool hasItem = !mRoots.empty() && mRoots.front()->type == NodeType::WorkingTree;
if ( hasItem && !item ) {
beginDeleteRows( {}, 0, 0 );
mRoots.erase( mRoots.begin() );
endDeleteRows();
} else if ( !hasItem && item ) {
beginInsertRows( {}, 0, 0 );
mRoots.insert( mRoots.begin(), std::move( item ) );
endInsertRows();
} else if ( hasItem && item ) {
mRoots.front()->message = std::move( item->message );
mRoots.front()->tooltip = std::move( item->tooltip );
}
invalidate( Model::DontInvalidateIndexes );
}
void GitHistoryModel::setRootError( std::string error ) {
mRoots.clear();
auto item = std::make_unique<Node>();
@@ -13,7 +13,7 @@ class GitPlugin;
class GitHistoryModel : public Model {
public:
enum class NodeType : uint8_t { Commit, LoadMore, Loading, Error, Empty };
enum class NodeType : uint8_t { WorkingTree, Commit, LoadMore, Loading, Error, Empty };
enum Column { Subject, Date, Author, Hash };
struct Node {
Git::Commit commit;
@@ -65,7 +65,10 @@ class GitHistoryModel : public Model {
void setRootLoading();
void setRootPage( Git::HistoryPage page, const Git::HistoryQuery& query );
void setRootPage( Git::HistoryPage page, const Git::HistoryQuery& query,
const Git::Status& status, const std::string& repoName );
void setWorkingTreeStatus( const Git::Status& status, const std::string& repoName );
void setRootError( std::string error );
@@ -94,6 +97,8 @@ class GitHistoryModel : public Model {
std::unique_ptr<Node> commitNode( Git::Commit commit, Node* parent,
const Git::HistoryQuery& query ) const;
std::unique_ptr<Node> workingTreeNode( const Git::Status& status,
const std::string& repoName ) const;
void fillPage( Nodes& nodes, Node* parent, Git::HistoryPage page,
const Git::HistoryQuery& query );
@@ -29,7 +29,8 @@ Sizef GitHistoryTreeViewCell::updateLayout() {
void GitHistoryTreeViewCell::updateCell( Model* model ) {
auto* historyModel = static_cast<GitHistoryModel*>( model );
const auto* item = historyModel->node( getCurIndex() );
if ( !item || item->type != GitHistoryModel::NodeType::Commit ) {
if ( !item || ( item->type != GitHistoryModel::NodeType::Commit &&
item->type != GitHistoryModel::NodeType::WorkingTree ) ) {
mMetadataText.setString( "" );
mTextBox->setTextAlign( UI_HALIGN_LEFT | UI_VALIGN_CENTER );
return;
+90 -40
View File
@@ -739,6 +739,16 @@ void GitPlugin::updateStatus( bool force ) {
[conflictStates = std::move( conflictStates )]( GitPlugin* plugin ) mutable {
const bool selectStatusPanel = plugin->updateConflictSessions( conflictStates );
plugin->updateStatusBarSync();
if ( plugin->mHistoryLoaded && plugin->mHistoryModel ) {
Git::Status status;
{
Lock l( plugin->mGitStatusMutex );
status = plugin->mGitStatus;
}
const std::string repo = plugin->repoSelected();
plugin->mHistoryModel->setWorkingTreeStatus(
status, plugin->mGit->repoName( repo, true, repo ) );
}
if ( selectStatusPanel && plugin->mPanelSwicher )
plugin->mPanelSwicher->getListBox()->setSelected( 1 );
} );
@@ -2437,18 +2447,22 @@ void GitPlugin::reloadHistory() {
mThreadPool->run(
[git = std::move( git ), lifetime, repo, generation, query]() mutable {
auto page = git->history( query, repo );
lifetime.run(
[repo, generation, query, page = std::move( page )]( GitPlugin* plugin ) mutable {
if ( plugin->mShuttingDown || generation != plugin->mHistoryGeneration ||
repo != plugin->repoSelected() )
return;
plugin->mHistoryLoaded = true;
if ( page.success() )
plugin->mHistoryModel->setRootPage( std::move( page ), query );
else
plugin->mHistoryModel->setRootError( std::move( page.result ) );
plugin->focusDetachedHistory();
} );
auto status = git->status( false, repo );
auto repoName = git->repoName( repo, true, repo );
lifetime.run( [repo, generation, query, page = std::move( page ),
status = std::move( status ),
repoName = std::move( repoName )]( GitPlugin* plugin ) mutable {
if ( plugin->mShuttingDown || generation != plugin->mHistoryGeneration ||
repo != plugin->repoSelected() )
return;
plugin->mHistoryLoaded = true;
if ( page.success() )
plugin->mHistoryModel->setRootPage( std::move( page ), query, status,
repoName );
else
plugin->mHistoryModel->setRootError( std::move( page.result ) );
plugin->focusDetachedHistory();
} );
},
[this]( auto ) { --mRunningHistoryRequests; } );
}
@@ -2626,9 +2640,12 @@ void GitPlugin::showGitHistory( const Git::Commit* commit ) {
openHistoryMenu( modelEvent->getModelIndex() );
} );
mDetachedHistory.tree->setOnSelection( [this]( const ModelIndex& index ) {
if ( const auto* node = mHistoryModel ? mHistoryModel->node( index ) : nullptr;
node && node->type == GitHistoryModel::NodeType::Commit )
openDetachedCommitDetails( node->commit );
if ( const auto* node = mHistoryModel ? mHistoryModel->node( index ) : nullptr; node ) {
if ( node->type == GitHistoryModel::NodeType::WorkingTree )
openWorkingTreeDetails( true );
else if ( node->type == GitHistoryModel::NodeType::Commit )
openDetachedCommitDetails( node->commit );
}
} );
auto* view = mDetachedHistory.view;
mDetachedHistory.closeConnection =
@@ -2666,16 +2683,24 @@ void GitPlugin::focusDetachedHistory() {
if ( !index.isValid() )
return;
mDetachedHistory.tree->setSelection( index, true, true );
if ( const auto* node = mHistoryModel->node( index );
node && node->type == GitHistoryModel::NodeType::Commit &&
( !mDetachedHistory.details.view ||
mDetachedHistory.details.commit.hash != node->commit.hash ) )
openDetachedCommitDetails( node->commit );
if ( const auto* node = mHistoryModel->node( index ); node ) {
if ( node->type == GitHistoryModel::NodeType::WorkingTree )
openWorkingTreeDetails( true );
else if ( node->type == GitHistoryModel::NodeType::Commit &&
( !mDetachedHistory.details.view ||
mDetachedHistory.details.commit.hash != node->commit.hash ) )
openDetachedCommitDetails( node->commit );
}
}
void GitPlugin::openDetachedCommitDetails( const Git::Commit& commit ) {
if ( commit.hash.empty() || !mDetachedHistory.view )
return;
ensureDetachedCommitDetailsHost();
mDetachedHistory.details.openCommitDetails( *this, commit, true );
}
void GitPlugin::ensureDetachedCommitDetailsHost() {
if ( !mDetachedHistory.detailsHost ) {
mDetachedHistory.detailsHost = UILinearLayout::NewVertical();
mDetachedHistory.detailsHost->setId( "git_history_detached_details" );
@@ -2684,30 +2709,38 @@ void GitPlugin::openDetachedCommitDetails( const Git::Commit& commit ) {
mDetachedHistory.detailsHost->setParent( mDetachedHistory.view );
mDetachedHistory.view->setSplitPartition( StyleSheetLength( "55%" ) );
}
mDetachedHistory.details.openCommitDetails( *this, commit, true );
}
void GitPlugin::openCommitDetails( const Git::Commit& commit ) {
mCommitDetails.openCommitDetails( *this, commit, false );
}
void GitPlugin::openWorkingTreeDetails( bool detached ) {
Git::Commit commit;
commit.subject = i18n( "git_working_tree_index", "Working Tree / Index" );
if ( detached )
ensureDetachedCommitDetailsHost();
( detached ? mDetachedHistory.details : mCommitDetails )
.openCommitDetails( *this, commit, detached, true );
}
void GitPlugin::CommitDetailsState::openCommitDetails( GitPlugin& plugin, const Git::Commit& commit,
bool detached ) {
if ( commit.hash.empty() )
bool detached, bool isWorkingTree ) {
if ( commit.hash.empty() && !isWorkingTree )
return;
const std::string selectedRepo = plugin.repoSelected();
const Uint64 requestGeneration = ++generation;
this->commit = commit;
workingTree = isWorkingTree;
repo = selectedRepo;
const bool createView = !view;
if ( createView ) {
view = plugin.getUISceneNode()->loadLayoutFromString( R"xml(
<vbox id="git_commit_details" lw="mp" lh="mp">
<vbox lw="mp" lh="wc" padding="12dp">
<vbox lw="mp" lh="wc" padding="4dp">
<hbox lw="mp" lh="wc">
<TextView id="git_commit_author" lw="wc" lh="wc" word-wrap="true"
focusable="false" />
<TextView id="git_commit_author" lw="wc" lh="wc" focusable="false" />
<PushButton id="git_commit_message_toggle"
text="@string(git_expand_commit_description, Expand Commit Description)"
icon="icon(unfold, 12dp)" text-as-fallback="true"
@@ -2819,9 +2852,11 @@ void GitPlugin::CommitDetailsState::openCommitDetails( GitPlugin& plugin, const
}
subject->setText( String::fromUtf8( commit.subject ) );
author->setText( String::fromUtf8( commit.authorName ) );
dateEmail->setText( Sys::epochToString( commit.commitTime ) + " - " +
String::fromUtf8( commit.authorEmail ) );
author->setText( isWorkingTree ? plugin.i18n( "git_uncommitted_changes", "Uncommitted changes" )
: String::fromUtf8( commit.authorName ) );
dateEmail->setText( isWorkingTree ? String{}
: Sys::epochToString( commit.commitTime ) + " - " +
String::fromUtf8( commit.authorEmail ) );
messageBody.clear();
messageExpanded = false;
message->setText( "" );
@@ -2835,13 +2870,16 @@ void GitPlugin::CommitDetailsState::openCommitDetails( GitPlugin& plugin, const
filesToggle->setIcon( icon->createDrawable( PixelDensity::dpToPxI( 12 ) ) );
url.clear();
gitHub->setVisible( false );
view->find( "git_commit_sha" )->setVisible( !isWorkingTree );
view->find( "git_commit_show_history" )->setVisible( !isWorkingTree );
diff = nullptr;
diffContainer->closeAllChildren();
auto* shaButton = view->find<UIPushButton>( "git_commit_sha" );
shaButton->setTooltipText( String::format(
plugin.i18n( "git_copy_commit_sha", "Copy Commit SHA\n%s" ).toUtf8(), commit.hash ) );
const std::string tabName = commit.shortHash + " " + commit.subject;
const std::string tabName =
isWorkingTree ? commit.subject : commit.shortHash + " " + commit.subject;
if ( !detached && !plugin.mManager->getSplitter()->ownedWidgetExists( view ) ) {
auto tab = plugin.mManager->getSplitter()->createWidget( view, tabName, true ).first;
if ( tab )
@@ -2861,16 +2899,20 @@ void GitPlugin::CommitDetailsState::loadCommitFiles( GitPlugin& plugin, bool det
const Uint64 generation = this->generation;
const std::string repo = this->repo;
const Git::Commit commit = this->commit;
const bool workingTree = this->workingTree;
auto git = plugin.mGit;
const auto lifetime = plugin.mLifetime.weakHandle();
plugin.runAsyncTask( [git = std::move( git ), lifetime, generation, repo, commit, detached] {
auto result = git->commitFiles( commit, repo );
lifetime.run( [generation, repo, commit, detached,
plugin.runAsyncTask( [git = std::move( git ), lifetime, generation, repo, commit, detached,
workingTree] {
auto result =
workingTree ? git->workingTreeFiles( repo ) : git->commitFiles( commit, repo );
lifetime.run( [generation, repo, commit, detached, workingTree,
result = std::move( result )]( GitPlugin* plugin ) mutable {
auto& details = detached ? plugin->mDetachedHistory.details : plugin->mCommitDetails;
if ( plugin->mShuttingDown || generation != details.generation ||
repo != details.repo || repo != plugin->repoSelected() ||
commit.hash != details.commit.hash || !details.view ||
commit.hash != details.commit.hash || workingTree != details.workingTree ||
!details.view ||
( detached
? !plugin->mDetachedHistory.view
: !plugin->mManager->getSplitter()->ownedWidgetExists( details.view ) ) )
@@ -2932,6 +2974,7 @@ void GitPlugin::CommitDetailsState::loadCommitFiles( GitPlugin& plugin, bool det
SizePolicy::MatchParent );
details.diff->setParent( details.diffContainer );
for ( auto* diff : UIDiffView::multiFileDiffViews( details.diff ) ) {
diff->setInteractiveFileHeader( true );
if ( const auto* scheme = plugin->getPluginContext()->getCurrentColorScheme() )
diff->setSyntaxColorScheme( *scheme );
}
@@ -3185,8 +3228,9 @@ void GitPlugin::buildSidePanelTab() {
</vbox>
<vbox id="git_history" lw="mp" lh="mp">
<hbox lw="mp" lh="wc" padding="4dp">
<DropDownModelList id="git_history_ref" lw="0" lw8="1" lh="wc" menu-width-mode="expand-if-needed" />
<PushButton id="git_history_refresh" text="@string(git_history_refresh, Refresh)" icon="icon(refresh, 12dp)" text-as-fallback="true" />
<DropDownModelList id="git_history_ref" lw="0" lw8="1" lh="wc" margin-right="2dp" menu-width-mode="expand-if-needed" />
<PushButton id="git_history_refresh" text="@string(git_history_refresh, Refresh)" icon="icon(refresh, 12dp)" text-as-fallback="true" margin-right="2dp" />
<PushButton id="git_history_open" tooltip="@string(git_show_in_history, Show in Git History)" icon="icon(history, 12dp)" />
</hbox>
<GitHistoryTreeView id="git_history_tree" lw="mp" lh="0" lw8="1" />
</vbox>
@@ -3227,6 +3271,7 @@ void GitPlugin::buildSidePanelTab() {
mTabContents->find( "branch_push" )->onClick( [this]( auto ) { push( repoSelected() ); } );
mTabContents->find( "branch_add" )->onClick( [this]( auto ) { branchCreate(); } );
mTabContents->find( "git_history_refresh" )->onClick( [this]( auto ) { reloadHistory(); } );
mTabContents->find( "git_history_open" )->onClick( [this]( auto ) { showGitHistory(); } );
mHistoryRefDropDown->getListView()->setColumnsVisible( { 0 } );
mHistoryRefDropDown->getListView()->setAutoExpandOnSingleColumn( true );
mHistoryRefDropDown->on( Event::OnItemSelected, [this]( const Event* ) {
@@ -3344,9 +3389,12 @@ void GitPlugin::buildSidePanelTab() {
activateHistoryIndex( modelEvent->getModelIndex(), true );
else if ( modelEvent->getModelEventType() == ModelEventType::Open ) {
activateHistoryIndex( modelEvent->getModelIndex(), false );
if ( const auto* node = mHistoryModel->node( modelEvent->getModelIndex() );
node && node->type == GitHistoryModel::NodeType::Commit )
openCommitDetails( node->commit );
if ( const auto* node = mHistoryModel->node( modelEvent->getModelIndex() ); node ) {
if ( node->type == GitHistoryModel::NodeType::WorkingTree )
openWorkingTreeDetails( false );
else if ( node->type == GitHistoryModel::NodeType::Commit )
openCommitDetails( node->commit );
}
} else if ( modelEvent->getModelEventType() == ModelEventType::OpenMenu )
openHistoryMenu( modelEvent->getModelIndex() );
} );
@@ -3354,7 +3402,9 @@ void GitPlugin::buildSidePanelTab() {
if ( !mHistoryModel )
return;
const auto* node = mHistoryModel->node( index );
if ( node && node->type == GitHistoryModel::NodeType::Commit )
if ( node && node->type == GitHistoryModel::NodeType::WorkingTree )
openWorkingTreeDetails( false );
else if ( node && node->type == GitHistoryModel::NodeType::Commit )
openCommitDetails( node->commit );
} );
+6 -1
View File
@@ -190,8 +190,10 @@ class GitPlugin : public PluginBase {
Tools::UIDiffView::ViewMode viewMode{ Tools::UIDiffView::ViewMode::Unified };
bool messageExpanded{ false };
bool filesCollapsed{ false };
bool workingTree{ false };
void openCommitDetails( GitPlugin& plugin, const Git::Commit& commit, bool detached );
void openCommitDetails( GitPlugin& plugin, const Git::Commit& commit, bool detached,
bool workingTree = false );
void loadCommitFiles( GitPlugin& plugin, bool detached );
@@ -216,6 +218,7 @@ class GitPlugin : public PluginBase {
viewMode = Tools::UIDiffView::ViewMode::Unified;
messageExpanded = false;
filesCollapsed = false;
workingTree = false;
}
};
struct DetachedHistoryState {
@@ -374,6 +377,7 @@ class GitPlugin : public PluginBase {
void activateHistoryIndex( const ModelIndex& index, bool expand );
void openCommitDetails( const Git::Commit& commit );
void openWorkingTreeDetails( bool detached );
void showGitHistory( const Git::Commit* commit = nullptr );
@@ -382,6 +386,7 @@ class GitPlugin : public PluginBase {
void openHistoryMenu( const ModelIndex& index );
void openDetachedCommitDetails( const Git::Commit& commit );
void ensureDetachedCommitDetailsHost();
std::string detachedHistoryTitle();