Revision control
Copy as Markdown
// Copyright (C) 2026 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
//
// qtdiff — lists the Qt classes whose header or source files differ between
// local HEAD and remote HEAD.
//
// Usage:
// qtdiff [--repo <path>] [--branch <name>]
//
// Options:
// --repo <path> Directory inside the git repository to operate on
// (default: current working directory).
// --branch <name> Diff against origin/<name> instead of the
// auto-detected remote HEAD (e.g. "--branch 6.12" to
// diff against a release branch). Applied uniformly to
// --repo and every submodule.
//
// By default, "remote HEAD" is the current branch's configured upstream
// (@{upstream}) when on a named branch, or the remote's default branch
// (origin/HEAD) when HEAD is detached — the normal state for a submodule
// pinned to a fixed commit by its superproject. --branch overrides this
// entirely with a fixed origin/<name> ref for every repository scanned.
//
// --repo (or the current directory) is always diffed directly first. If it
// also declares submodules (has a .gitmodules — e.g. a qt5 superproject
// checkout, or a module like qtdeclarative/qtwebengine that bundles its own
// test-data/vendored submodule), every initialized submodule is diffed too
// — one is never a substitute for the other, since a module can have real
// Qt sources of its own *and* an incidental nested submodule. Submodule
// nesting is followed exactly one level deep: a submodule's own further
// submodules (e.g. qtwebengine's vendored Chromium tree) are never
// expanded, since they are not Qt sources. The repo's own classes are
// printed unprefixed; a submodule's are printed as "<submodule>: <ClassName>".
//
// Exits non-zero (with a message on stderr) when: not inside a git
// repository, or (for a repo with no submodules) HEAD is detached with no
// origin/HEAD, or the current branch has no upstream configured. Otherwise
// prints the sorted, de-duplicated list of Qt class names touched by the
// diff, one per line, or "No Qt classes changed." if no repository in scope
// had any qualifying result. A file whose scan crashes or exceeds the
// per-file timeout (see kScanTimeoutMs) is skipped with a warning on
// stderr rather than aborting the run — real-world diffs can include
// adversarial-scale vendored code (e.g. Chromium sources reachable through
// qtwebengine) that can defeat libstdc++'s std::regex.
#include "scanner.h"
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <map>
#include <regex>
#include <set>
#include <sstream>
#include <string>
#include <vector>
#ifdef _WIN32
# define WIN32_LEAN_AND_MEAN
# define NOMINMAX
# include <windows.h>
#else
# include <fcntl.h>
# include <poll.h>
# include <signal.h>
# include <spawn.h>
# include <sys/wait.h>
# include <unistd.h>
extern char **environ;
#endif
// Per-file wall-clock budget for the out-of-process class-name scan (see
// scanFileInSubprocess below). Real Qt/Chromium source files scan in low
// single-digit milliseconds; this only ever engages for the rare
// pathological file that trips libstdc++'s std::regex into extreme
// slowness or (observed in practice, on ordinary qtdeclarative sources)
// a stack-overflow crash — both are handled the same way: the scan runs in
// a disposable child process, so a crash or a timed-out kill only costs us
// that one file's classes, never the rest of the run.
static constexpr int kScanTimeoutMs = 5000;
namespace fs = std::filesystem;
namespace {
struct CommandResult {
int exitCode = -1;
std::string output;
};
// Runs `git <args...>` with cwd set to workDir, via posix_spawn/CreateProcess
// with an explicit argv array — never a shell string — so that ref/branch
// names (which are effectively attacker-influenceable: anyone who can push
// a branch controls their text) cannot inject shell metacharacters.
// git's own stderr chatter (e.g. "fatal: no upstream configured for branch")
// is discarded — callers report their own, more specific messages.
#ifdef _WIN32
CommandResult runGit(const std::vector<std::string> &args, const fs::path &workDir)
{
SECURITY_ATTRIBUTES sa = { };
sa.nLength = sizeof(sa);
sa.bInheritHandle = TRUE;
HANDLE readPipe = nullptr, writePipe = nullptr;
if (!CreatePipe(&readPipe, &writePipe, &sa, 0))
return { -1, { } };
SetHandleInformation(readPipe, HANDLE_FLAG_INHERIT, 0);
HANDLE nulHandle = CreateFileA("NUL", GENERIC_WRITE, FILE_SHARE_WRITE, &sa,
OPEN_EXISTING, 0, nullptr);
std::string cmd = "git -C \"" + workDir.string() + "\"";
for (const auto &a : args)
cmd += " \"" + a + "\"";
STARTUPINFOA si = { };
si.cb = sizeof(si);
si.dwFlags = STARTF_USESTDHANDLES;
si.hStdOutput = writePipe;
si.hStdError = nulHandle ? nulHandle : writePipe;
si.hStdInput = GetStdHandle(STD_INPUT_HANDLE);
PROCESS_INFORMATION pi = { };
BOOL ok = CreateProcessA(nullptr, const_cast<char *>(cmd.c_str()), nullptr, nullptr, TRUE, 0,
nullptr, nullptr, &si, &pi);
CloseHandle(writePipe);
if (nulHandle)
CloseHandle(nulHandle);
if (!ok) {
CloseHandle(readPipe);
return { -1, { } };
}
CloseHandle(pi.hThread);
std::string output;
char buf[4096];
DWORD n = 0;
while (ReadFile(readPipe, buf, sizeof(buf), &n, nullptr) && n > 0)
output.append(buf, n);
CloseHandle(readPipe);
WaitForSingleObject(pi.hProcess, INFINITE);
DWORD code = 0;
GetExitCodeProcess(pi.hProcess, &code);
CloseHandle(pi.hProcess);
return { static_cast<int>(code), output };
}
#else // POSIX
CommandResult runGit(const std::vector<std::string> &args, const fs::path &workDir)
{
int outPipe[2];
if (pipe(outPipe) != 0)
return { -1, { } };
std::vector<std::string> argv = { "git", "-C", workDir.string() };
argv.insert(argv.end(), args.begin(), args.end());
std::vector<char *> cArgv;
cArgv.reserve(argv.size() + 1);
for (auto &a : argv)
cArgv.push_back(const_cast<char *>(a.c_str()));
cArgv.push_back(nullptr);
posix_spawn_file_actions_t actions;
posix_spawn_file_actions_init(&actions);
posix_spawn_file_actions_adddup2(&actions, outPipe[1], STDOUT_FILENO);
posix_spawn_file_actions_addclose(&actions, outPipe[0]);
posix_spawn_file_actions_addclose(&actions, outPipe[1]);
posix_spawn_file_actions_addopen(&actions, STDERR_FILENO, "/dev/null", O_WRONLY, 0);
pid_t pid = -1;
int rc = posix_spawnp(&pid, "git", &actions, nullptr, cArgv.data(), environ);
posix_spawn_file_actions_destroy(&actions);
close(outPipe[1]);
if (rc != 0) {
close(outPipe[0]);
return { -1, { } };
}
std::string output;
char buf[4096];
ssize_t n;
while ((n = read(outPipe[0], buf, sizeof(buf))) > 0)
output.append(buf, static_cast<size_t>(n));
close(outPipe[0]);
int status = 0;
waitpid(pid, &status, 0);
CommandResult result;
result.exitCode = WIFEXITED(status) ? WEXITSTATUS(status) : -1;
result.output = output;
return result;
}
#endif
struct ScanResult {
bool ok = false;
bool timedOut = false;
bool crashed = false;
std::string output;
};
// Runs argv[0] (an absolute path to this same qtdiff binary) with the given
// arguments, capturing stdout, and hard-kills it if it hasn't exited within
// timeoutMs. Used exclusively to run the class-name scan for one file in a
// disposable child process — see the kScanTimeoutMs comment above main().
#ifdef _WIN32
ScanResult spawnCapture(const std::vector<std::string> &argv, int timeoutMs)
{
ScanResult result;
SECURITY_ATTRIBUTES sa = { };
sa.nLength = sizeof(sa);
sa.bInheritHandle = TRUE;
HANDLE readPipe = nullptr, writePipe = nullptr;
if (!CreatePipe(&readPipe, &writePipe, &sa, 0))
return result;
SetHandleInformation(readPipe, HANDLE_FLAG_INHERIT, 0);
HANDLE nulHandle = CreateFileA("NUL", GENERIC_WRITE, FILE_SHARE_WRITE, &sa,
OPEN_EXISTING, 0, nullptr);
std::string cmd;
for (const auto &a : argv)
cmd += (cmd.empty() ? "" : " ") + ("\"" + a + "\"");
STARTUPINFOA si = { };
si.cb = sizeof(si);
si.dwFlags = STARTF_USESTDHANDLES;
si.hStdOutput = writePipe;
si.hStdError = nulHandle ? nulHandle : writePipe;
si.hStdInput = nullptr;
PROCESS_INFORMATION pi = { };
BOOL ok = CreateProcessA(nullptr, const_cast<char *>(cmd.c_str()), nullptr, nullptr, TRUE, 0,
nullptr, nullptr, &si, &pi);
CloseHandle(writePipe);
if (nulHandle)
CloseHandle(nulHandle);
if (!ok) {
CloseHandle(readPipe);
return result;
}
CloseHandle(pi.hThread);
// Drain the pipe while waiting so a chatty child can never fill the
// pipe buffer and deadlock against our own wait loop, then enforce the
// timeout with a bounded WaitForSingleObject poll.
std::string output;
char buf[4096];
bool exited = false;
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeoutMs);
while (true) {
DWORD avail = 0;
if (PeekNamedPipe(readPipe, nullptr, 0, nullptr, &avail, nullptr) && avail > 0) {
DWORD n = 0;
if (ReadFile(readPipe, buf, sizeof(buf), &n, nullptr) && n > 0)
output.append(buf, n);
}
DWORD waitRc = WaitForSingleObject(pi.hProcess, 20);
if (waitRc == WAIT_OBJECT_0) {
exited = true;
break;
}
if (std::chrono::steady_clock::now() >= deadline) {
TerminateProcess(pi.hProcess, 124);
WaitForSingleObject(pi.hProcess, INFINITE);
result.timedOut = true;
break;
}
}
if (exited) {
DWORD n = 0;
while (ReadFile(readPipe, buf, sizeof(buf), &n, nullptr) && n > 0)
output.append(buf, n);
}
CloseHandle(readPipe);
if (result.timedOut) {
CloseHandle(pi.hProcess);
return result;
}
DWORD code = 0;
GetExitCodeProcess(pi.hProcess, &code);
CloseHandle(pi.hProcess);
result.crashed = (code != 0);
result.ok = !result.crashed;
result.output = output;
return result;
}
#else // POSIX
ScanResult spawnCapture(const std::vector<std::string> &argv, int timeoutMs)
{
ScanResult result;
int outPipe[2];
if (pipe(outPipe) != 0)
return result;
std::vector<char *> cArgv;
cArgv.reserve(argv.size() + 1);
for (auto &a : argv)
cArgv.push_back(const_cast<char *>(a.c_str()));
cArgv.push_back(nullptr);
posix_spawn_file_actions_t actions;
posix_spawn_file_actions_init(&actions);
posix_spawn_file_actions_adddup2(&actions, outPipe[1], STDOUT_FILENO);
posix_spawn_file_actions_addclose(&actions, outPipe[0]);
posix_spawn_file_actions_addclose(&actions, outPipe[1]);
posix_spawn_file_actions_addopen(&actions, STDERR_FILENO, "/dev/null", O_WRONLY, 0);
pid_t pid = -1;
int rc = posix_spawn(&pid, argv[0].c_str(), &actions, nullptr, cArgv.data(), environ);
posix_spawn_file_actions_destroy(&actions);
close(outPipe[1]);
if (rc != 0) {
close(outPipe[0]);
return result;
}
// Drain the pipe while waiting (via poll's own timeout as the tick) so
// a chatty child can never fill the pipe buffer and deadlock against
// our own wait loop, then enforce the overall timeout across ticks.
std::string output;
char buf[4096];
bool exited = false;
int status = 0;
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeoutMs);
while (true) {
struct pollfd pfd = { outPipe[0], POLLIN, 0 };
if (poll(&pfd, 1, 20) > 0 && (pfd.revents & (POLLIN | POLLHUP))) {
ssize_t n = read(outPipe[0], buf, sizeof(buf));
if (n > 0)
output.append(buf, static_cast<size_t>(n));
}
if (waitpid(pid, &status, WNOHANG) == pid) {
exited = true;
break;
}
if (std::chrono::steady_clock::now() >= deadline) {
kill(pid, SIGKILL);
waitpid(pid, &status, 0);
result.timedOut = true;
break;
}
}
if (exited) {
ssize_t n;
while ((n = read(outPipe[0], buf, sizeof(buf))) > 0)
output.append(buf, static_cast<size_t>(n));
}
close(outPipe[0]);
if (result.timedOut)
return result;
result.crashed = !WIFEXITED(status) || WEXITSTATUS(status) != 0;
result.ok = !result.crashed;
result.output = output;
return result;
}
#endif
std::string trim(const std::string &s)
{
size_t a = s.find_first_not_of(" \t\n\r");
if (a == std::string::npos)
return { };
size_t b = s.find_last_not_of(" \t\n\r");
return s.substr(a, b - a + 1);
}
std::vector<std::string> splitLines(const std::string &s)
{
std::vector<std::string> lines;
std::istringstream iss(s);
std::string line;
while (std::getline(iss, line)) {
line = trim(line);
if (!line.empty())
lines.push_back(line);
}
return lines;
}
void usage(const char *prog)
{
std::cerr << "Usage: " << prog << " [--repo <path>] [--branch <name>]\n"
<< "Lists the Qt classes whose header/source files differ between\n"
<< "the current branch's local HEAD and its remote HEAD.\n"
<< "\n"
<< "\"Remote HEAD\" is, by default, the current branch's configured\n"
<< "upstream (@{upstream}) when on a named branch, or the remote's\n"
<< "default branch (origin/HEAD) when HEAD is detached — e.g. a\n"
<< "submodule pinned to a fixed commit by its superproject.\n"
<< "\n"
<< "--branch <name> Diff against origin/<name> instead (e.g.\n"
<< " '--branch 6.12' to diff against a release\n"
<< " branch rather than the default branch). Applied\n"
<< " uniformly to --repo and every submodule; a\n"
<< " submodule where that branch doesn't exist\n"
<< " remotely is skipped with a warning.\n"
<< "\n"
<< "--repo is always diffed directly. If it also declares submodules\n"
<< "(has .gitmodules), every initialized submodule is diffed too, one\n"
<< "level deep; each submodule's results are printed as\n"
<< "\"<submodule>: <ClassName>\".\n";
}
// Result of diffing a single git repository (not a superproject traversal).
struct RepoResult {
bool ok = false;
std::string error; // set when !ok
std::set<std::string> classes;
};
// Resolves "remote HEAD" for repoRoot: origin/<branchOverride> when one is
// given (e.g. "6.12" for a release branch); otherwise the current branch's
// upstream when on a named branch, or origin/HEAD when detached
// (submodule-pinned checkouts have no current branch at all — origin/HEAD
// is the most literal reading of "remote HEAD" available in that state).
// Returns {ok, ref, error}.
struct RemoteHeadResult {
bool ok = false;
std::string ref;
std::string error;
};
RemoteHeadResult resolveRemoteHead(const fs::path &repoRoot, const std::string &branchOverride)
{
if (!branchOverride.empty()) {
const std::string ref = "origin/" + branchOverride;
if (runGit({ "rev-parse", "--verify", "--quiet", ref }, repoRoot).exitCode != 0) {
return { false, { }, "remote branch '" + ref + "' not found" };
}
return { true, ref, { } };
}
auto branchResult = runGit({ "rev-parse", "--abbrev-ref", "HEAD" }, repoRoot);
const std::string branch = trim(branchResult.output);
if (branchResult.exitCode == 0 && !branch.empty() && branch != "HEAD") {
auto upstreamResult = runGit(
{ "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}" }, repoRoot);
const std::string upstream = trim(upstreamResult.output);
if (upstreamResult.exitCode != 0 || upstream.empty()) {
return { false, { },
"branch '" + branch + "' has no upstream — nothing to diff against" };
}
return { true, upstream, { } };
}
// Detached HEAD: fall back to the remote's default branch pointer.
auto symrefResult = runGit({ "symbolic-ref", "refs/remotes/origin/HEAD" }, repoRoot);
const std::string symref = trim(symrefResult.output);
static const std::string kRemotesPrefix = "refs/remotes/";
if (symrefResult.exitCode != 0 || symref.rfind(kRemotesPrefix, 0) != 0) {
return { false, { },
"detached HEAD and no origin/HEAD to diff against" };
}
return { true, symref.substr(kRemotesPrefix.size()), { } };
}
// Maps relative path -> list of (startLine, lineCount) ranges on the HEAD
// (old) side that differ from remoteRef, parsed from a single
// `git diff --unified=0 HEAD remoteRef` invocation (one call for the whole
// repo, rather than one per changed file). Zero-context unified diff hunk
// headers look like "@@ -START[,COUNT] +newStart[,newCount] @@"; COUNT
// defaults to 1 when omitted, and a COUNT of 0 (pure insertion, nothing
// removed from the HEAD side at that point) contributes no HEAD-side range.
//
// This is what lets qtdiff attribute a changed file to only the specific
// classes whose own declaration/definition lines actually differ, instead
// of every class merely *present* in that file — otherwise a file like
// qdom.cpp, which defines methods for a few dozen QDom* classes, would
// attribute all of them to a single one-line change anywhere in the file.
std::map<std::string, std::vector<std::pair<int, int>>>
changedLineRanges(const fs::path &repoRoot, const std::string &remoteRef)
{
std::map<std::string, std::vector<std::pair<int, int>>> result;
auto diffResult = runGit({ "diff", "--unified=0", "HEAD", remoteRef }, repoRoot);
if (diffResult.exitCode != 0)
return result;
static const std::string kFilePrefix = "diff --git a/";
static const std::regex kHunkRe(R"(^@@ -(\d+)(?:,(\d+))? \+)");
std::string currentFile;
for (const auto &line : splitLines(diffResult.output)) {
if (line.rfind(kFilePrefix, 0) == 0) {
const std::string rest = line.substr(kFilePrefix.size());
const size_t sep = rest.find(" b/");
currentFile = (sep != std::string::npos) ? rest.substr(0, sep) : std::string();
continue;
}
std::smatch m;
if (!currentFile.empty() && std::regex_search(line, m, kHunkRe)) {
const int start = std::stoi(m[1].str());
const int count = m[2].matched ? std::stoi(m[2].str()) : 1;
if (count > 0)
result[currentFile].push_back({ start, count });
}
}
return result;
}
// Scans one file's already-fetched content for Qt class names, restricted
// to classes whose declaration/definition overlaps changedRanges, out of
// process (see kScanTimeoutMs): writes content to a throwaway temp file,
// re-invokes this same qtdiff binary with a hidden internal flag that
// reads that file, filters by the given line ranges, and prints the
// result, hard-killing it if it doesn't finish in time. A crash or timeout
// is reported to stderr and treated as "no classes found for this file"
// rather than aborting the run.
std::vector<std::string> scanFileInSubprocess(const std::string &selfExe,
const std::string &relPath,
const std::string &content,
bool isHeader,
const std::vector<std::pair<int, int>> &changedRanges)
{
static int counter = 0;
const auto nowNs = std::chrono::steady_clock::now().time_since_epoch().count();
std::error_code ec;
const fs::path tmpFile = fs::temp_directory_path(ec)
/ ("qtdiff-scan-" + std::to_string(nowNs)
+ "-" + std::to_string(counter++)
+ (isHeader ? ".h" : ".cpp"));
{
std::ofstream out(tmpFile, std::ios::binary);
out << content;
}
std::vector<std::string> argv = { selfExe,
isHeader ? "--internal-scan-header" : "--internal-scan-source",
tmpFile.string() };
for (const auto &range : changedRanges)
argv.push_back(std::to_string(range.first) + "," + std::to_string(range.second));
ScanResult scan = spawnCapture(argv, kScanTimeoutMs);
fs::remove(tmpFile, ec);
if (!scan.ok) {
std::cerr << "qtdiff: " << relPath
<< (scan.timedOut ? ": scan timed out — skipping" : ": scan crashed — skipping")
<< "\n";
return { };
}
return splitLines(scan.output);
}
// Diffs a single git repository's local HEAD against its resolved remote
// HEAD and returns the Qt classes touched by that diff.
RepoResult processRepo(const fs::path &repo, const std::string &selfExe,
const std::string &branchOverride)
{
RepoResult result;
if (runGit({ "rev-parse", "--is-inside-work-tree" }, repo).exitCode != 0) {
result.error = "not inside a git repository";
return result;
}
const std::string topLevel = trim(runGit({ "rev-parse", "--show-toplevel" }, repo).output);
if (topLevel.empty()) {
result.error = "could not determine repository root";
return result;
}
const fs::path repoRoot = topLevel;
RemoteHeadResult remoteHead = resolveRemoteHead(repoRoot, branchOverride);
if (!remoteHead.ok) {
result.error = remoteHead.error;
return result;
}
auto diffResult = runGit({ "diff", "--name-only", "HEAD", remoteHead.ref }, repoRoot);
if (diffResult.exitCode != 0) {
result.error = "'git diff' against " + remoteHead.ref + " failed";
return result;
}
const auto lineRanges = changedLineRanges(repoRoot, remoteHead.ref);
for (const auto &relPath : splitLines(diffResult.output)) {
const fs::path ext = fs::path(relPath).extension();
const bool isHeader = (ext == ".h" || ext == ".hpp");
const bool isSource = (ext == ".cpp" || ext == ".cc" || ext == ".cxx");
if (!isHeader && !isSource)
continue;
auto showResult = runGit({ "show", "HEAD:" + relPath }, repoRoot);
if (showResult.exitCode != 0)
continue; // deleted at HEAD relative to remote HEAD — nothing to attribute
static const std::vector<std::pair<int, int>> kNoRanges;
const auto rangesIt = lineRanges.find(relPath);
const auto &ranges = (rangesIt != lineRanges.end()) ? rangesIt->second : kNoRanges;
for (auto &name : scanFileInSubprocess(selfExe, relPath, showResult.output, isHeader,
ranges))
result.classes.insert(std::move(name));
}
result.ok = true;
return result;
}
// Returns the submodule paths declared in repoRoot/.gitmodules (relative to
// repoRoot), via git's own config parser — not text scraping — so quoting
// and formatting edge cases in .gitmodules are handled correctly.
std::vector<std::string> submodulePaths(const fs::path &repoRoot)
{
std::vector<std::string> paths;
if (!fs::exists(repoRoot / ".gitmodules"))
return paths;
auto listResult = runGit({ "config", "-f", ".gitmodules", "--get-regexp", "\\.path$" },
repoRoot);
for (const auto &line : splitLines(listResult.output)) {
const size_t sp = line.find(' ');
if (sp != std::string::npos)
paths.push_back(line.substr(sp + 1));
}
return paths;
}
// Resolves the absolute path to this same running qtdiff binary, so
// scanFileInSubprocess() can re-invoke the exact program that is currently
// running rather than guessing. argv[0] alone is not reliable for this: for
// the common case of an installed CLI tool invoked by bare name (just
// "qtdiff", found via $PATH by the shell), fs::absolute(argv[0]) silently
// resolves it against the *current working directory* instead — producing
// a path that doesn't exist, so every re-exec fails and every file's scan
// is misreported as crashed.
std::string resolveSelfExe(const char *argv0)
{
#ifdef _WIN32
char buf[MAX_PATH];
DWORD n = GetModuleFileNameA(nullptr, buf, sizeof(buf));
if (n > 0 && n < sizeof(buf))
return std::string(buf, n);
#elif defined(__linux__)
std::error_code ec;
fs::path self = fs::read_symlink("/proc/self/exe", ec);
if (!ec && !self.empty())
return self.string();
#endif
// Fallback (macOS, or if the platform-specific lookup above failed): if
// argv0 contains a path separator, exec() semantics already resolved it
// relative to cwd or as an absolute path — fs::absolute() is correct.
// Otherwise it was found via a $PATH search when this process was
// launched; search PATH ourselves so the re-exec uses that same binary.
const std::string argv0Str(argv0);
if (argv0Str.find('/') != std::string::npos || argv0Str.find('\\') != std::string::npos) {
std::error_code ec;
return fs::absolute(argv0Str, ec).string();
}
if (const char *pathEnv = std::getenv("PATH")) {
std::istringstream iss(pathEnv);
std::string dir;
while (std::getline(iss, dir, ':')) {
std::error_code ec;
fs::path candidate = fs::path(dir) / argv0Str;
if (fs::exists(candidate, ec))
return candidate.string();
}
}
return argv0Str; // last resort — spawnCapture will just fail cleanly
}
} // namespace
int main(int argc, char *argv[])
{
// Hidden internal mode: re-invoked by scanFileInSubprocess() on this
// same binary to run the class-name scan for one file out of process
// (see kScanTimeoutMs) — never invoked directly by a user. Reads the
// given file, computes each class's declaration/definition span, keeps
// only the ones overlapping at least one "start,count" changed-line
// range given as the remaining arguments, and prints the resulting
// distinct class names, one per line. No range arguments at all means
// no class in this file has any of its own lines different from
// HEAD (see the note on overlapsAnyRange below) — correctly prints
// nothing, not "everything".
if (argc >= 3
&& (std::string(argv[1]) == "--internal-scan-header"
|| std::string(argv[1]) == "--internal-scan-source")) {
const bool isHeader = std::string(argv[1]) == "--internal-scan-header";
std::ifstream f(argv[2], std::ios::binary);
std::ostringstream ss;
ss << f.rdbuf();
const std::string content = ss.str();
std::vector<std::pair<int, int>> ranges;
for (int i = 3; i < argc; ++i) {
const std::string tok = argv[i];
const size_t comma = tok.find(',');
if (comma != std::string::npos)
ranges.push_back({ std::atoi(tok.substr(0, comma).c_str()),
std::atoi(tok.substr(comma + 1).c_str()) });
}
// Note: an empty ranges list is not "no filtering requested" — it
// means the diff for this file consisted entirely of hunks with
// zero HEAD-side lines (e.g. the remote side merely *added* an
// #ifdef guard around code that already exists, unchanged, in
// HEAD — see qlabel.cpp's "@@ -193,0 +194 @@" style hunks). In
// that case correctly no class in this file has any of its own
// lines different from HEAD, so nothing should match.
const auto overlapsAnyRange = [&](int startLine, int endLine) {
for (const auto &r : ranges) {
const int rangeStart = r.first;
const int rangeEnd = r.first + r.second - 1;
if (startLine <= rangeEnd && endLine >= rangeStart)
return true;
}
return false;
};
const std::vector<QtFuzz::ClassSpan> spans = isHeader
? QtFuzz::classDeclarationSpansIn(content)
: QtFuzz::classDefinitionSpansIn(content);
std::set<std::string> matched;
for (const auto &span : spans) {
if (overlapsAnyRange(span.startLine, span.endLine))
matched.insert(span.className);
}
for (const auto &name : matched)
std::cout << name << "\n";
return 0;
}
fs::path repo = fs::current_path();
std::string branchOverride;
for (int i = 1; i < argc; ++i) {
std::string a = argv[i];
if (a == "--help" || a == "-h") {
usage(argv[0]);
return 0;
} else if (a == "--repo" && i + 1 < argc) {
repo = argv[++i];
} else if (a == "--branch" && i + 1 < argc) {
branchOverride = argv[++i];
} else {
std::cerr << "qtdiff: unknown argument: " << a << "\n";
usage(argv[0]);
return 1;
}
}
const std::string selfExe = resolveSelfExe(argv[0]);
if (runGit({ "rev-parse", "--is-inside-work-tree" }, repo).exitCode != 0) {
std::cerr << "qtdiff: not inside a git repository\n";
return 1;
}
const std::string topLevel = trim(runGit({ "rev-parse", "--show-toplevel" }, repo).output);
if (topLevel.empty()) {
std::cerr << "qtdiff: could not determine repository root\n";
return 1;
}
const fs::path repoRoot = topLevel;
const std::vector<std::string> submodules = submodulePaths(repoRoot);
bool anySucceeded = false;
bool printedAny = false;
// Always scan the target's own diff — a repo can have real Qt sources
// of its own *and* an incidental submodule (e.g. qtdeclarative has its
// own huge QML/Quick source tree plus a nested ecmascript test-data
// submodule; qtwebengine has its own Qt-facing API plus the vendored
// Chromium submodule) — one is never a substitute for the other.
RepoResult own = processRepo(repoRoot, selfExe, branchOverride);
if (own.ok) {
anySucceeded = true;
for (const auto &name : own.classes) {
std::cout << name << "\n";
printedAny = true;
}
} else if (submodules.empty()) {
std::cerr << "qtdiff: " << own.error << "\n";
return 1;
}
// else: the target itself isn't cleanly diffable as a whole (e.g. a
// pure gitlink superproject with no meaningful branch of its own), but
// it may still have real submodules worth diffing below.
for (const auto &sub : submodules) {
const fs::path subPath = repoRoot / sub;
if (!fs::exists(subPath / ".git")) {
std::cerr << "qtdiff: " << sub << ": submodule not initialized — skipping\n";
continue;
}
RepoResult result = processRepo(subPath, selfExe, branchOverride);
if (!result.ok) {
std::cerr << "qtdiff: " << sub << ": " << result.error << "\n";
continue;
}
anySucceeded = true;
for (const auto &name : result.classes) {
std::cout << sub << ": " << name << "\n";
printedAny = true;
}
}
if (!anySucceeded) {
std::cerr << "qtdiff: no repositories could be diffed\n";
return 1;
}
if (!printedAny)
std::cout << "No Qt classes changed.\n";
return 0;
}