GitHub Repository Size Checker

Displays the repo size without .git

  1. // ==UserScript==
  2. // @name GitHub Repository Size Checker
  3. // @namespace https://github.com/yookibooki
  4. // @version 1.0
  5. // @description Displays the repo size without .git
  6. // @match *://github.com/*/*
  7. // @exclude *://github.com/*/issues*
  8. // @exclude *://github.com/*/pulls*
  9. // @exclude *://github.com/*/actions*
  10. // @exclude *://github.com/*/projects*
  11. // @exclude *://github.com/*/wiki*
  12. // @exclude *://github.com/*/security*
  13. // @exclude *://github.com/*/pulse*
  14. // @exclude *://github.com/*/settings*
  15. // @exclude *://github.com/*/branches*
  16. // @exclude *://github.com/*/tags*
  17. // @exclude *://github.com/*/*/commit*
  18. // @exclude *://github.com/*/*/tree*
  19. // @exclude *://github.com/*/*/blob*
  20. // @exclude *://github.com/settings*
  21. // @exclude *://github.com/notifications*
  22. // @exclude *://github.com/marketplace*
  23. // @exclude *://github.com/explore*
  24. // @exclude *://github.com/topics*
  25. // @exclude *://github.com/sponsors*
  26. // @exclude *://github.com/dashboard*
  27. // @exclude *://github.com/new*
  28. // @exclude *://github.com/codespaces*
  29. // @exclude *://github.com/account*
  30. // @grant GM_setValue
  31. // @grant GM_getValue
  32. // @grant GM_xmlhttpRequest
  33. // @grant GM_registerMenuCommand
  34. // @connect api.github.com
  35. // ==/UserScript==
  36.  
  37. (function() {
  38. 'use strict';
  39.  
  40. const CACHE_KEY = 'repoSizeCache';
  41. const PAT_KEY = 'github_pat_repo_size';
  42. const CACHE_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
  43.  
  44. // --- Configuration ---
  45. const GITHUB_API_BASE = 'https://api.github.com';
  46. // Target element selector (this might change if GitHub updates its layout)
  47. const TARGET_ELEMENT_SELECTOR = '#repo-title-component > span.Label.Label--secondary'; // The 'Public'/'Private' label
  48. const DISPLAY_ELEMENT_ID = 'repo-size-checker-display';
  49.  
  50. // --- Styles ---
  51. const STYLE_LOADING = 'color: orange; margin-left: 6px; font-size: 12px; font-weight: 600;';
  52. const STYLE_ERROR = 'color: red; margin-left: 6px; font-size: 12px; font-weight: 600;';
  53. const STYLE_SIZE = 'color: #6a737d; margin-left: 6px; font-size: 12px; font-weight: 600;'; // Use GitHub's secondary text color
  54.  
  55. let currentRepoInfo = null; // { owner, repo, key: 'owner/repo' }
  56. let pat = null;
  57. let displayElement = null;
  58. let observer = null; // MutationObserver to watch for page changes
  59.  
  60. // --- Helper Functions ---
  61.  
  62. function log(...args) {
  63. console.log('[RepoSizeChecker]', ...args);
  64. }
  65.  
  66. function getRepoInfoFromUrl() {
  67. const match = window.location.pathname.match(/^\/([^/]+)\/([^/]+)(?:\/?$|\/tree\/|\/find\/|\/graphs\/|\/network\/|\/releases\/)/);
  68. if (match && match[1] && match[2]) {
  69. // Basic check to avoid non-code pages that might match the pattern
  70. if (document.querySelector('#repository-container-header')) {
  71. return { owner: match[1], repo: match[2], key: `${match[1]}/${match[2]}` };
  72. }
  73. }
  74. return null;
  75. }
  76.  
  77. function formatBytes(bytes, decimals = 1) {
  78. if (bytes === 0) return '0 Bytes';
  79. const k = 1024;
  80. const dm = decimals < 0 ? 0 : decimals;
  81. const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
  82. const i = Math.floor(Math.log(bytes) / Math.log(k));
  83. return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
  84. }
  85.  
  86. function getPAT() {
  87. if (pat) return pat;
  88. pat = GM_getValue(PAT_KEY, null);
  89. return pat;
  90. }
  91.  
  92. function setPAT(newPat) {
  93. if (newPat && typeof newPat === 'string' && newPat.trim().length > 0) {
  94. pat = newPat.trim();
  95. GM_setValue(PAT_KEY, pat);
  96. log('GitHub PAT saved.');
  97. // Clear current error message if any
  98. if (displayElement && displayElement.textContent?.includes('PAT Required')) {
  99. updateDisplay('', STYLE_LOADING); // Reset display
  100. }
  101. // Re-run the main logic if PAT was missing
  102. main();
  103. return true;
  104. } else {
  105. GM_setValue(PAT_KEY, ''); // Clear stored PAT if input is invalid/empty
  106. pat = null;
  107. log('Invalid PAT input. PAT cleared.');
  108. updateDisplay('Invalid PAT', STYLE_ERROR);
  109. return false;
  110. }
  111. }
  112.  
  113. function promptForPAT() {
  114. const newPat = prompt('GitHub Personal Access Token (PAT) required for API access. Please enter your PAT (needs repo scope):\n\nIt will be stored locally by Tampermonkey.', '');
  115. if (newPat === null) { // User cancelled
  116. updateDisplay('PAT Required', STYLE_ERROR);
  117. return false;
  118. }
  119. return setPAT(newPat);
  120. }
  121.  
  122. function getCache() {
  123. const cacheStr = GM_getValue(CACHE_KEY, '{}');
  124. try {
  125. return JSON.parse(cacheStr);
  126. } catch (e) {
  127. log('Error parsing cache, resetting.', e);
  128. GM_setValue(CACHE_KEY, '{}');
  129. return {};
  130. }
  131. }
  132.  
  133. function setCache(repoKey, data) {
  134. try {
  135. const cache = getCache();
  136. cache[repoKey] = data;
  137. GM_setValue(CACHE_KEY, JSON.stringify(cache));
  138. } catch (e) {
  139. log('Error writing cache', e);
  140. }
  141. }
  142.  
  143. function updateDisplay(text, style = STYLE_SIZE, isLoading = false) {
  144. if (!displayElement) {
  145. const targetElement = document.querySelector(TARGET_ELEMENT_SELECTOR);
  146. if (!targetElement) {
  147. log('Target element not found.');
  148. return; // Target element isn't on the page yet or selector is wrong
  149. }
  150. displayElement = document.createElement('span');
  151. displayElement.id = DISPLAY_ELEMENT_ID;
  152. targetElement.insertAdjacentElement('afterend', displayElement);
  153. log('Display element injected.');
  154. }
  155.  
  156. displayElement.textContent = isLoading ? `(${text}...)` : text;
  157. displayElement.style.cssText = style;
  158. }
  159.  
  160. function makeApiRequest(url, method = 'GET') {
  161. return new Promise((resolve, reject) => {
  162. const currentPat = getPAT();
  163. if (!currentPat) {
  164. reject(new Error('PAT Required'));
  165. return;
  166. }
  167.  
  168. GM_xmlhttpRequest({
  169. method: method,
  170. url: url,
  171. headers: {
  172. "Authorization": `token ${currentPat}`,
  173. "Accept": "application/vnd.github.v3+json"
  174. },
  175. onload: function(response) {
  176. if (response.status >= 200 && response.status < 300) {
  177. try {
  178. resolve(JSON.parse(response.responseText));
  179. } catch (e) {
  180. reject(new Error(`Failed to parse API response: ${e.message}`));
  181. }
  182. } else if (response.status === 401) {
  183. reject(new Error('Invalid PAT'));
  184. } else if (response.status === 403) {
  185. const rateLimitRemaining = response.responseHeaders.match(/x-ratelimit-remaining:\s*(\d+)/i);
  186. const rateLimitReset = response.responseHeaders.match(/x-ratelimit-reset:\s*(\d+)/i);
  187. let errorMsg = 'API rate limit exceeded or insufficient permissions.';
  188. if (rateLimitRemaining && rateLimitRemaining[1] === '0' && rateLimitReset) {
  189. const resetTime = new Date(parseInt(rateLimitReset[1], 10) * 1000);
  190. errorMsg += ` Limit resets at ${resetTime.toLocaleTimeString()}.`;
  191. } else {
  192. errorMsg += ' Check PAT permissions (needs `repo` scope).';
  193. }
  194. reject(new Error(errorMsg));
  195. } else if (response.status === 404) {
  196. reject(new Error('Repository not found or PAT lacks access.'));
  197. }
  198. else {
  199. reject(new Error(`API request failed with status ${response.status}: ${response.statusText}`));
  200. }
  201. },
  202. onerror: function(response) {
  203. reject(new Error(`Network error during API request: ${response.error || 'Unknown error'}`));
  204. },
  205. ontimeout: function() {
  206. reject(new Error('API request timed out.'));
  207. }
  208. });
  209. });
  210. }
  211.  
  212. async function fetchLatestDefaultBranchSha(owner, repo) {
  213. log(`Fetching repo info for ${owner}/${repo}`);
  214. const repoUrl = `${GITHUB_API_BASE}/repos/${owner}/${repo}`;
  215. try {
  216. const repoData = await makeApiRequest(repoUrl);
  217. const defaultBranch = repoData.default_branch;
  218. if (!defaultBranch) {
  219. throw new Error('Could not determine default branch.');
  220. }
  221. log(`Default branch: ${defaultBranch}. Fetching its latest SHA.`);
  222. const branchUrl = `${GITHUB_API_BASE}/repos/${owner}/${repo}/branches/${defaultBranch}`;
  223. const branchData = await makeApiRequest(branchUrl);
  224. return branchData.commit.sha;
  225. } catch (error) {
  226. log(`Error fetching latest SHA for ${owner}/${repo}:`, error);
  227. throw error; // Re-throw to be caught by the main logic
  228. }
  229. }
  230.  
  231. async function fetchRepoTreeSize(owner, repo, sha) {
  232. log(`Fetching tree size for ${owner}/${repo} at SHA ${sha}`);
  233. const treeUrl = `${GITHUB_API_BASE}/repos/${owner}/${repo}/git/trees/${sha}?recursive=1`;
  234. try {
  235. const treeData = await makeApiRequest(treeUrl);
  236.  
  237. if (treeData.truncated && (!treeData.tree || treeData.tree.length === 0)) {
  238. // Handle extremely large repos where even the first page is truncated without file list
  239. throw new Error('Repo likely too large for basic tree API. Size unavailable.');
  240. }
  241.  
  242. let totalSize = 0;
  243. if (treeData.tree) {
  244. treeData.tree.forEach(item => {
  245. if (item.type === 'blob' && item.size !== undefined && item.size !== null) {
  246. totalSize += item.size;
  247. }
  248. });
  249. }
  250.  
  251. log(`Calculated size for ${owner}/${repo} (SHA: ${sha}): ${totalSize} bytes. Truncated: ${treeData.truncated}`);
  252. return {
  253. size: totalSize,
  254. truncated: treeData.truncated === true // Ensure boolean
  255. };
  256. } catch (error) {
  257. log(`Error fetching tree size for ${owner}/${repo}:`, error);
  258. // Special handling for empty repos which return 404 for the tree SHA
  259. if (error.message && error.message.includes('404') && error.message.includes('Not Found')) {
  260. log(`Assuming empty repository for ${owner}/${repo} based on 404 for tree SHA ${sha}.`);
  261. return { size: 0, truncated: false };
  262. }
  263. throw error; // Re-throw other errors
  264. }
  265. }
  266.  
  267. async function main() {
  268. const repoInfo = getRepoInfoFromUrl();
  269.  
  270. // Exit if not on a repo page or already processed this exact URL path
  271. if (!repoInfo || (currentRepoInfo && currentRepoInfo.key === repoInfo.key && currentRepoInfo.path === window.location.pathname)) {
  272. // log('Not a repo page or already processed:', window.location.pathname);
  273. return;
  274. }
  275.  
  276. currentRepoInfo = { ...repoInfo, path: window.location.pathname }; // Store owner, repo, key, and full path
  277. log('Detected repository:', currentRepoInfo.key);
  278.  
  279. // Ensure display element exists or create it
  280. updateDisplay('loading', STYLE_LOADING, true);
  281.  
  282. // Check for PAT
  283. if (!getPAT()) {
  284. log('PAT not found.');
  285. updateDisplay('PAT Required', STYLE_ERROR);
  286. promptForPAT(); // Ask user for PAT
  287. // If promptForPAT fails or is cancelled, the display remains 'PAT Required'
  288. return; // Stop processing until PAT is provided
  289. }
  290.  
  291. // --- Caching Logic ---
  292. const cache = getCache();
  293. const cachedData = cache[currentRepoInfo.key];
  294. const now = Date.now();
  295.  
  296. if (cachedData) {
  297. const cacheAge = now - (cachedData.timestamp || 0);
  298. log(`Cache found for ${currentRepoInfo.key}: Age ${Math.round(cacheAge / 1000)}s, SHA ${cachedData.sha}`);
  299.  
  300. // 1. Check if cache is fresh (less than 24 hours)
  301. if (cacheAge < CACHE_EXPIRY_MS) {
  302. log('Cache is fresh (<24h). Using cached size.');
  303. updateDisplay(
  304. `${cachedData.truncated ? '~' : ''}${formatBytes(cachedData.size)}`,
  305. STYLE_SIZE
  306. );
  307. return; // Use fresh cache
  308. }
  309.  
  310. // 2. Cache is older than 24 hours, check if SHA matches current default branch head
  311. log('Cache is stale (>24h). Checking latest SHA...');
  312. updateDisplay('validating', STYLE_LOADING, true);
  313. try {
  314. const latestSha = await fetchLatestDefaultBranchSha(currentRepoInfo.owner, currentRepoInfo.repo);
  315. log(`Latest SHA: ${latestSha}, Cached SHA: ${cachedData.sha}`);
  316.  
  317. if (latestSha === cachedData.sha) {
  318. log('SHA matches. Reusing cached size and updating timestamp.');
  319. // Update timestamp in cache
  320. cachedData.timestamp = now;
  321. setCache(currentRepoInfo.key, cachedData);
  322. updateDisplay(
  323. `${cachedData.truncated ? '~' : ''}${formatBytes(cachedData.size)}`,
  324. STYLE_SIZE
  325. );
  326. return; // Use validated cache
  327. } else {
  328. log('SHA mismatch. Cache invalid. Fetching new size.');
  329. }
  330. } catch (error) {
  331. log('Error validating SHA:', error);
  332. updateDisplay(`Error: ${error.message}`, STYLE_ERROR);
  333. // Optionally clear the stale cache entry if validation fails badly?
  334. // delete cache[currentRepoInfo.key];
  335. // GM_setValue(CACHE_KEY, JSON.stringify(cache));
  336. return; // Stop if we can't validate
  337. }
  338. } else {
  339. log(`No cache found for ${currentRepoInfo.key}.`);
  340. }
  341.  
  342. // --- Fetching New Data ---
  343. updateDisplay('loading', STYLE_LOADING, true);
  344. try {
  345. // We might have already fetched the SHA during cache validation
  346. let latestSha = cachedData?.latestShaChecked; // Reuse if available from failed validation
  347. if (!latestSha) {
  348. latestSha = await fetchLatestDefaultBranchSha(currentRepoInfo.owner, currentRepoInfo.repo);
  349. }
  350.  
  351. const { size, truncated } = await fetchRepoTreeSize(currentRepoInfo.owner, currentRepoInfo.repo, latestSha);
  352.  
  353. // Save to cache
  354. const newData = {
  355. size: size,
  356. sha: latestSha,
  357. timestamp: Date.now(),
  358. truncated: truncated
  359. };
  360. setCache(currentRepoInfo.key, newData);
  361.  
  362. // Display result
  363. updateDisplay(
  364. `${truncated ? '~' : ''}${formatBytes(size)}`,
  365. STYLE_SIZE
  366. );
  367.  
  368. } catch (error) {
  369. log('Error during main fetch process:', error);
  370. let errorMsg = `Error: ${error.message}`;
  371. if (error.message === 'Invalid PAT') {
  372. errorMsg = 'Invalid PAT';
  373. setPAT(''); // Clear invalid PAT
  374. promptForPAT(); // Ask again
  375. } else if (error.message === 'PAT Required') {
  376. errorMsg = 'PAT Required';
  377. promptForPAT();
  378. }
  379. updateDisplay(errorMsg, STYLE_ERROR);
  380. }
  381. }
  382.  
  383. // --- Initialization ---
  384.  
  385. function init() {
  386. log("Script initializing...");
  387.  
  388. // Register menu command to update PAT
  389. GM_registerMenuCommand('Set/Update GitHub PAT for Repo Size', () => {
  390. const currentPatValue = GM_getValue(PAT_KEY, '');
  391. const newPat = prompt('Enter your GitHub Personal Access Token (PAT) for Repo Size Checker (needs repo scope):', currentPatValue);
  392. if (newPat !== null) { // Handle cancel vs empty string
  393. setPAT(newPat); // Validate and save
  394. }
  395. });
  396.  
  397. // Use MutationObserver to detect navigation changes within GitHub (SPA behavior)
  398. // and when the target element appears after load.
  399. observer = new MutationObserver((mutationsList, observer) => {
  400. // Check if the repo title area is present, indicating a potential repo page load/update
  401. if (document.querySelector(TARGET_ELEMENT_SELECTOR) && !document.getElementById(DISPLAY_ELEMENT_ID)) {
  402. // If the display element isn't there but the target is, try running main
  403. log("Target element detected, running main logic.");
  404. main();
  405. } else {
  406. // Also check if the URL path changed significantly enough to warrant a re-check
  407. const newRepoInfo = getRepoInfoFromUrl();
  408. if (newRepoInfo && (!currentRepoInfo || newRepoInfo.key !== currentRepoInfo.key)) {
  409. log("Detected navigation to a new repository page.", newRepoInfo.key);
  410. main();
  411. } else if (!newRepoInfo && currentRepoInfo) {
  412. // Navigated away from a repo page where we were showing info
  413. log("Navigated away from repo page.");
  414. currentRepoInfo = null; // Reset state
  415. if (displayElement) {
  416. displayElement.remove(); // Clean up old display element
  417. displayElement = null;
  418. }
  419. }
  420. }
  421. });
  422.  
  423. // Start observing the body for changes in subtree and child list
  424. observer.observe(document.body, { childList: true, subtree: true });
  425.  
  426. // Initial run in case the page is already loaded
  427. main();
  428. }
  429.  
  430.  
  431. // Make sure the DOM is ready before trying to find elements
  432. if (document.readyState === 'loading') {
  433. document.addEventListener('DOMContentLoaded', init);
  434. } else {
  435. init();
  436. }
  437.  
  438. })();