Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`)

Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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 <tom.larcher@gmail.com>",
"license": "MIT",
Expand Down Expand Up @@ -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}\"",
Expand Down
2 changes: 1 addition & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const PLUGIN_VERSION = '0.3.0';
export const PLUGIN_VERSION = '0.3.1';
116 changes: 93 additions & 23 deletions src/github/githubClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export interface CommitFilesOptions {
token: string;
commitMessage: string;
files: FileCommitPayload[];
baseBranch?: string;
}

export interface CommitFilesResult {
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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<boolean> {
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 }),
Expand Down Expand Up @@ -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<UpsertResult> {
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);
Expand Down Expand Up @@ -502,13 +533,13 @@ export async function upsertFile(options: GitHubUpsertOptions): Promise<UpsertRe
}

export async function commitFiles(options: CommitFilesOptions): Promise<CommitFilesResult> {
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);

Expand Down Expand Up @@ -537,6 +568,45 @@ export async function commitFiles(options: CommitFilesOptions): Promise<CommitFi
};
}

export interface RemoteHashLookupOptions {
owner: string;
repo: string;
branch: string;
token: string;
paths: string[];
}

/**
* Fetches embedded content hashes for a set of files from a specific branch.
*
* @returns Map of repo-relative path → content hash (null if file missing or hash absent)
*/
export async function getRemoteFileHashes(
options: RemoteHashLookupOptions
): Promise<Record<string, string | null>> {
const { owner, repo, branch, token, paths } = options;
const uniquePaths = Array.from(new Set(paths));
const result: Record<string, string | null> = {};

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,
Expand Down
3 changes: 3 additions & 0 deletions src/messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') */
Expand Down Expand Up @@ -120,6 +122,7 @@ export function defaultSettings(): PersistedSettings {
owner: '',
repo: '',
branch: 'main',
defaultBranch: '',
folder: '',
filename: 'variables.json',
commitPrefix: '',
Expand Down
51 changes: 43 additions & 8 deletions src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
/// <reference types="@figma/plugin-typings" />
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';
Expand Down Expand Up @@ -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({
Expand All @@ -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<string, string | null> = {};
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;
}
Expand All @@ -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 };
Expand Down
17 changes: 7 additions & 10 deletions src/ui/components/export/ActionButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -67,17 +67,14 @@ export const ActionButtons: FunctionComponent = () => {

{commitState.success && !commitState.skipped && (
<Banner variant="success" icon={<IconInfoSmall24 />}>
<Text>
Committed successfully!
<Stack space="extraSmall">
<Text>Committed successfully!</Text>
{commitState.url && (
<>
{' '}
<a href={commitState.url} target="_blank" rel="noopener noreferrer">
View commit →
</a>
</>
<a href={commitState.url} target="_blank" rel="noopener noreferrer">
View commit →
</a>
)}
</Text>
</Stack>
</Banner>
)}

Expand Down
Loading