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