GitHub Repository Size Checker (Excluding .git)

Displays the total size of files in a GitHub repository (excluding .git directory) next to the repo name, using smart caching.

当前为 2025-04-13 提交的版本,查看 最新版本

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