From 383a72fc2958fca74a2526d914f1fe334d50254e Mon Sep 17 00:00:00 2001 From: Tom Larcher Date: Fri, 12 Dec 2025 15:23:11 +1100 Subject: [PATCH] feat: add support for configurable base branch in GitHub operations - FigGit updated to version `0.3.1` accounting for a packaging bug (erroneous package archive structure/composition). - Added functionality for nominating a base/default branch separately from the target branch for the exported tokens. This provides affordances for consumer repositories to nuke their target branch arbitrarily, and for the FigGit plugin to compute a diff from the base/default branch in those cases. - Fixed a minor bug affecting the clickable "View Commit" link presented upon successful export commit. --- README.md | 18 +-- package.json | 4 +- src/constants.ts | 2 +- src/github/githubClient.ts | 116 ++++++++++++++---- src/messaging.ts | 3 + src/plugin.ts | 51 ++++++-- src/ui/components/export/ActionButtons.tsx | 17 ++- src/ui/components/export/StatusSummary.tsx | 11 +- .../settings/RepositoryDisclosure.tsx | 10 +- src/util/validation.ts | 6 + 10 files changed, 184 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index fb7b7ba..11b70ac 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ A Figma plugin that extracts design variables (colors, typography, spacing, etc. - **One-Click Export**: Extract all Figma variables with full metadata - **Tab-Based Interface**: Organized UI with Export, Preview, and Settings tabs - **Direct GitHub Integration**: Commit directly to any branch (creates branch if needed) +- **Flexible Branch Diffing**: Compare the export against the target branch first, then fall back to a configurable default branch to skip redundant commits - **Smart Change Detection**: SHA-256 content hashing prevents redundant commits - **Diff Preview**: See token-level changes before committing (added/modified/removed) - **JSON Preview**: View and copy any exported JSON document (DTCG or Figma-native) directly in the plugin @@ -277,7 +278,8 @@ Configure your GitHub repository and authentication: - Enter GitHub owner (username or organization) - Enter repository name -- Specify target branch (will be created if it doesn't exist) +- Specify the target branch for token exports (will be created if it doesn't exist) +- (Optional) Provide the repository's default branch used for diff fallback and branch creation - (Optional) Specify folder path within the repo - Set output filename (must end with `.json`) @@ -341,12 +343,12 @@ update Figma variables (42 vars, 3 collections) - 2025-01-15T10:30:00.123Z ### Smart Change Detection -The plugin uses SHA-256 content hashing to detect changes: +The plugin uses SHA-256 content hashing plus targeted branch diffing to detect changes: -1. **Local Cache**: Stores hash of last export to skip unnecessary network calls -2. **Remote Comparison**: Compares with `$extensions.com.figma.contentHash` in remote file -3. **Fallback**: If remote file lacks hash (manual edits), recomputes hash for comparison -4. **Skip Logic**: Only commits if content actually changed +1. **Target Branch First**: Compares the export against the current target branch using the embedded `contentHash` field in each JSON payload +2. **Default Branch Fallback**: If the target branch does not exist yet, FigGit checks the user-configured default branch (or the target branch when unset) before creating any commits +3. **Hash Validation**: If remote files lack a `contentHash`, the plugin treats them as stale so the new export overwrites them with deterministic metadata +4. **Skip Logic**: When no differences are detected on the inspected branch, the commit is skipped automatically ### Dry Run Mode @@ -361,8 +363,8 @@ Enable dry run to: If the specified branch doesn't exist, the plugin: -1. Fetches the repository's default branch -2. Creates the new branch from the default branch HEAD +1. Fetches the configured default branch (or the repository's default if none is specified) +2. Creates the new branch from that base branch's HEAD 3. Commits the variables file to the new branch ### Conflict Resolution diff --git a/package.json b/package.json index 3105c04..be0bbfd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "figgit", - "version": "0.3.0", + "version": "0.3.1", "description": "A Figma plugin for exporting variables to GitHub.", "author": "Tom Larcher ", "license": "MIT", @@ -34,7 +34,7 @@ "build:manifest": "cp manifest.json dist/manifest.json", "build:assets": "mkdir -p dist/assets && cp assets/logo.png dist/assets/logo.png", "package": "npm run build && npm run package:zip", - "package:zip": "PLUGIN_VERSION=$(node -p \"require('./package.json').version\") && cd dist && zip -r ../figgit-$PLUGIN_VERSION.zip . && cd ..", + "package:zip": "PLUGIN_VERSION=$(node -p \"require('./package.json').version\") && rm -f figgit-$PLUGIN_VERSION.zip && zip -r figgit-$PLUGIN_VERSION.zip manifest.json dist -x \"*/.DS_Store\" \"dist/manifest.json\"", "lint": "eslint src --ext .ts,.tsx", "lint:fix": "eslint src --ext .ts,.tsx --fix", "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", diff --git a/src/constants.ts b/src/constants.ts index 2ed81c1..d571ec9 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1 +1 @@ -export const PLUGIN_VERSION = '0.3.0'; +export const PLUGIN_VERSION = '0.3.1'; diff --git a/src/github/githubClient.ts b/src/github/githubClient.ts index 8834ca4..c36a489 100644 --- a/src/github/githubClient.ts +++ b/src/github/githubClient.ts @@ -59,6 +59,7 @@ export interface CommitFilesOptions { token: string; commitMessage: string; files: FileCommitPayload[]; + baseBranch?: string; } export interface CommitFilesResult { @@ -89,6 +90,8 @@ export interface GitHubUpsertOptions { commitMessage: string; /** SHA-256 content hash for change detection (not Git blob hash) */ currentHash: string; + /** Optional base branch to create target branch from when missing */ + baseBranch?: string; } /** @@ -130,42 +133,69 @@ async function ghFetch(url: string, token: string, init: RequestInit = {}) { ); } +export async function branchExists( + owner: string, + repo: string, + branch: string, + token: string +): Promise { + const refUrl = `https://api.github.com/repos/${owner}/${repo}/git/ref/heads/${branch}`; + const ref = await ghFetch(refUrl, token); + + if (ref.status === 200) { + return true; + } + + if (ref.status === 404) { + return false; + } + + throw new Error(`Failed reading branch: ${ref.status}`); +} + /** * Ensures a branch exists in the repository. * - * If the branch doesn't exist, creates it from the default branch. - * If it already exists, does nothing. + * If the branch doesn't exist, creates it from a base branch: + * - User-provided base branch (preferred) + * - Repository default branch (fallback) * - * @param owner - GitHub username or organization - * @param repo - Repository name - * @param branch - Branch name to ensure exists - * @param token - GitHub PAT * @throws Error if unable to read repository or create branch */ -async function ensureBranch(owner: string, repo: string, branch: string, token: string) { - const refUrl = `https://api.github.com/repos/${owner}/${repo}/git/ref/heads/${branch}`; - const ref = await ghFetch(refUrl, token); +async function ensureBranch( + owner: string, + repo: string, + branch: string, + token: string, + baseBranch?: string +) { + const exists = await branchExists(owner, repo, branch, token); + if (exists) return; - if (ref.status === 200) return; // Branch exists - if (ref.status !== 404) throw new Error(`Failed reading branch: ${ref.status}`); + let sourceBranch = baseBranch?.trim(); - // Branch doesn't exist - create it from default branch - const repoRes = await ghFetch(`https://api.github.com/repos/${owner}/${repo}`, token); - if (!repoRes.ok) throw new Error('Cannot read repository metadata'); + if (!sourceBranch) { + const repoRes = await ghFetch(`https://api.github.com/repos/${owner}/${repo}`, token); + if (!repoRes.ok) throw new Error('Cannot read repository metadata'); - const repoJson = await repoRes.json(); - const defaultBranch = repoJson.default_branch; + const repoJson = await repoRes.json(); + sourceBranch = repoJson.default_branch; + } const baseRefRes = await ghFetch( - `https://api.github.com/repos/${owner}/${repo}/git/ref/heads/${defaultBranch}`, + `https://api.github.com/repos/${owner}/${repo}/git/ref/heads/${sourceBranch}`, token ); - if (!baseRefRes.ok) throw new Error('Cannot read default branch ref'); + if (!baseRefRes.ok) { + if (baseBranch) { + throw new Error(`Cannot read base branch ref (${sourceBranch})`); + } + throw new Error('Cannot read default branch ref'); + } const baseRef = await baseRefRes.json(); const sha = baseRef.object.sha; - // Create new branch pointing to default branch's HEAD const createRes = await ghFetch(`https://api.github.com/repos/${owner}/${repo}/git/refs`, token, { method: 'POST', body: JSON.stringify({ ref: `refs/heads/${branch}`, sha }), @@ -404,10 +434,11 @@ function extractEmbeddedHashFromJsonContent(content: string): string | undefined * @throws Error if unable to create/update file */ export async function upsertFile(options: GitHubUpsertOptions): Promise { - const { owner, repo, branch, path, content, token, commitMessage, currentHash } = options; + const { owner, repo, branch, path, content, token, commitMessage, currentHash, baseBranch } = + options; // Ensure target branch exists (create from default branch if needed) - await ensureBranch(owner, repo, branch, token); + await ensureBranch(owner, repo, branch, token, baseBranch); // Check if file already exists const existing = await getExistingFile(owner, repo, branch, path, token); @@ -502,13 +533,13 @@ export async function upsertFile(options: GitHubUpsertOptions): Promise { - const { owner, repo, branch, token, commitMessage, files } = options; + const { owner, repo, branch, token, commitMessage, files, baseBranch } = options; if (!files.length) { return { updated: false, skipped: true, updatedPaths: [] }; } - await ensureBranch(owner, repo, branch, token); + await ensureBranch(owner, repo, branch, token, baseBranch); const filesToUpdate = await filterFilesNeedingUpdate(owner, repo, branch, token, files); @@ -537,6 +568,45 @@ export async function commitFiles(options: CommitFilesOptions): Promise> { + const { owner, repo, branch, token, paths } = options; + const uniquePaths = Array.from(new Set(paths)); + const result: Record = {}; + + for (const path of uniquePaths) { + const existing = await getExistingFile(owner, repo, branch, path, token); + if (!existing) { + result[path] = null; + continue; + } + + try { + const decoded = fromBase64(existing.content); + const embeddedHash = extractEmbeddedHashFromJsonContent(decoded); + result[path] = embeddedHash ?? null; + } catch { + result[path] = null; + } + } + + return result; +} + async function filterFilesNeedingUpdate( owner: string, repo: string, diff --git a/src/messaging.ts b/src/messaging.ts index e44bb02..bb288ff 100644 --- a/src/messaging.ts +++ b/src/messaging.ts @@ -86,6 +86,8 @@ export interface PersistedSettings { repo: string; /** Target branch name (e.g., 'main', 'develop') */ branch: string; + /** Repository default branch used for diff fallback/branch creation */ + defaultBranch?: string; /** Optional folder path within repository (e.g., 'design-tokens') */ folder: string; /** Filename for exported JSON (e.g., 'variables.json') */ @@ -120,6 +122,7 @@ export function defaultSettings(): PersistedSettings { owner: '', repo: '', branch: 'main', + defaultBranch: '', folder: '', filename: 'variables.json', commitPrefix: '', diff --git a/src/plugin.ts b/src/plugin.ts index 92aa19d..5ab13a0 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -10,7 +10,7 @@ /// import { buildExportBundle } from './export/buildExportBundle'; import { SETTINGS_KEY, defaultSettings, UIToPluginMessage, PersistedSettings } from './messaging'; -import { commitFiles, fromBase64 } from './github/githubClient'; +import { commitFiles, fromBase64, branchExists, getRemoteFileHashes } from './github/githubClient'; import { stableStringify } from './util/stableStringify'; import { buildRepoPath } from './util/path'; import { validateAllSettings } from './util/validation'; @@ -111,6 +111,7 @@ async function handleCommitRequest(msg: CommitRequestMessage) { branch: settings.branch, filename: settings.filename, folder: settings.folder, + defaultBranch: settings.defaultBranch, }); if (!validationResult.valid) { figma.ui.postMessage({ @@ -122,11 +123,44 @@ async function handleCommitRequest(msg: CommitRequestMessage) { } const storedHashes = getLastHashMap(settings); - const pendingDocs = exportBundle.documents.filter( - (doc) => storedHashes[doc.relativePath] !== doc.contentHash - ); - if (!pendingDocs.length) { + const owner = settings.owner.trim(); + const repo = settings.repo.trim(); + const targetBranch = settings.branch.trim(); + const fallbackBranch = settings.defaultBranch?.trim() || targetBranch; + const docPaths = Array.from(new Set(exportBundle.documents.map((doc) => doc.relativePath))); + + const targetExists = await branchExists(owner, repo, targetBranch, token); + + let diffBranch: string | null = targetBranch; + if (!targetExists) { + if (fallbackBranch && fallbackBranch !== targetBranch) { + const fallbackExists = await branchExists(owner, repo, fallbackBranch, token); + if (!fallbackExists) { + throw new Error(`Default branch "${fallbackBranch}" not found in ${owner}/${repo}`); + } + diffBranch = fallbackBranch; + } else { + diffBranch = null; + } + } + + let remoteHashes: Record = {}; + if (diffBranch && docPaths.length) { + remoteHashes = await getRemoteFileHashes({ + owner, + repo, + branch: diffBranch, + token, + paths: docPaths, + }); + } + + const pendingDocs = diffBranch + ? exportBundle.documents.filter((doc) => remoteHashes[doc.relativePath] !== doc.contentHash) + : exportBundle.documents; + + if (diffBranch && !pendingDocs.length) { figma.ui.postMessage({ type: 'COMMIT_RESULT', ok: true, skipped: true }); return; } @@ -147,12 +181,13 @@ async function handleCommitRequest(msg: CommitRequestMessage) { })); const result = await commitFiles({ - owner: settings.owner, - repo: settings.repo, - branch: settings.branch, + owner, + repo, + branch: targetBranch, token, commitMessage, files, + baseBranch: fallbackBranch, }); const nextHashes = { ...storedHashes }; diff --git a/src/ui/components/export/ActionButtons.tsx b/src/ui/components/export/ActionButtons.tsx index b3dfc35..e353460 100644 --- a/src/ui/components/export/ActionButtons.tsx +++ b/src/ui/components/export/ActionButtons.tsx @@ -4,7 +4,7 @@ * Primary export and commit action buttons. */ -import { h, Fragment, FunctionComponent } from 'preact'; +import { h, FunctionComponent } from 'preact'; import { useCallback } from 'preact/hooks'; import { Stack, @@ -67,17 +67,14 @@ export const ActionButtons: FunctionComponent = () => { {commitState.success && !commitState.skipped && ( }> - - Committed successfully! + + Committed successfully! {commitState.url && ( - <> - {' '} - - View commit → - - + + View commit → + )} - + )} diff --git a/src/ui/components/export/StatusSummary.tsx b/src/ui/components/export/StatusSummary.tsx index 13891e4..67d686c 100644 --- a/src/ui/components/export/StatusSummary.tsx +++ b/src/ui/components/export/StatusSummary.tsx @@ -103,11 +103,20 @@ export const StatusSummary: FunctionComponent = () => { - Branch: + Target Branch: {settings.branch} + + {settings.defaultBranch && settings.defaultBranch !== settings.branch ? ( + + Default Branch: + + {settings.defaultBranch} + + + ) : null} ); diff --git a/src/ui/components/settings/RepositoryDisclosure.tsx b/src/ui/components/settings/RepositoryDisclosure.tsx index 45f90f3..4fd2533 100644 --- a/src/ui/components/settings/RepositoryDisclosure.tsx +++ b/src/ui/components/settings/RepositoryDisclosure.tsx @@ -56,9 +56,17 @@ export const RepositoryDisclosure: FunctionComponent = () => { updateSettings({ branch: e.currentTarget.value })} + placeholder="design-tokens" + > + Target Branch + + + updateSettings({ defaultBranch: e.currentTarget.value })} placeholder="main" > - Branch + Default Branch (Optional)