diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md
index 29e0767..40458fc 100644
--- a/docs/ENVIRONMENT_VARIABLES.md
+++ b/docs/ENVIRONMENT_VARIABLES.md
@@ -114,7 +114,7 @@ Standard GitHub Enterprise Cloud on `github.com` works with the default — no o
|----------|-------------|---------|---------|
| `MIRROR_ORGANIZATIONS` | Mirror organization repositories | `false` | `true`, `false` |
| `PRESERVE_ORG_STRUCTURE` | Preserve GitHub organization structure in Gitea | `false` | `true`, `false` |
-| `ONLY_MIRROR_ORGS` | Only mirror organization repos (skip personal) | `false` | `true`, `false` |
+| `ONLY_MIRROR_ORGS` | Only mirror organization repos (skip personal); sets `skipPersonalRepos: true` in GitHub config | `false` | `true`, `false` |
| `MIRROR_STRATEGY` | Repository organization strategy | `preserve` | `preserve`, `single-org`, `flat-user`, `mixed` |
### Advanced Settings
diff --git a/src/components/config/GitHubMirrorSettings.tsx b/src/components/config/GitHubMirrorSettings.tsx
index 0137410..a594723 100644
--- a/src/components/config/GitHubMirrorSettings.tsx
+++ b/src/components/config/GitHubMirrorSettings.tsx
@@ -927,6 +927,26 @@ export function GitHubMirrorSettings({
+
+
+
handleAdvancedChange('skipPersonalRepos', !!checked)}
+ />
+
+
+
+ Exclude repositories owned by your personal GitHub account; only mirror repos belonging to organizations
+
+
+
diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts
index 8eae4b3..c965803 100644
--- a/src/lib/db/schema.ts
+++ b/src/lib/db/schema.ts
@@ -34,6 +34,7 @@ export const githubConfigSchema = z.object({
autoMirrorStarred: z.boolean().default(false),
skipStarredIssues: z.boolean().optional(), // Deprecated: kept for backward compatibility, use starredCodeOnly instead
starredDuplicateStrategy: z.enum(["suffix", "prefix", "owner-org"]).default("suffix").optional(),
+ skipPersonalRepos: z.boolean().default(false),
});
export const backupStrategyEnum = z.enum([
@@ -156,7 +157,9 @@ export const configSchema = z.object({
isActive: z.boolean().default(true),
githubConfig: githubConfigSchema,
giteaConfig: giteaConfigSchema,
+ // Unused/reserved — stored for future glob support but not currently read
include: z.array(z.string()).default(["*"]),
+ // Unused/reserved — stored for future glob support but not currently read
exclude: z.array(z.string()).default([]),
scheduleConfig: scheduleConfigSchema,
cleanupConfig: cleanupConfigSchema,
diff --git a/src/lib/env-config-loader.ts b/src/lib/env-config-loader.ts
index 44921c5..686afa1 100644
--- a/src/lib/env-config-loader.ts
+++ b/src/lib/env-config-loader.ts
@@ -285,6 +285,8 @@ export async function initializeConfigFromEnv(): Promise {
starredCodeOnly: envConfig.github.starredCodeOnly ?? existingConfig?.[0]?.githubConfig?.starredCodeOnly ?? false,
autoMirrorStarred: envConfig.github.autoMirrorStarred ?? existingConfig?.[0]?.githubConfig?.autoMirrorStarred ?? false,
starredLists: envConfig.github.starredLists ?? existingConfig?.[0]?.githubConfig?.starredLists ?? [],
+ // ONLY_MIRROR_ORGS=true maps to skipPersonalRepos: true
+ skipPersonalRepos: envConfig.github.onlyMirrorOrgs ?? existingConfig?.[0]?.githubConfig?.skipPersonalRepos ?? false,
};
// Build Gitea config
diff --git a/src/lib/github-affiliation.test.ts b/src/lib/github-affiliation.test.ts
index c977f5d..eefab9e 100644
--- a/src/lib/github-affiliation.test.ts
+++ b/src/lib/github-affiliation.test.ts
@@ -1,15 +1,23 @@
import { describe, expect, test, mock } from "bun:test";
import { getGithubRepositories } from "@/lib/github";
-function makeRepo() {
+function makeRepo(overrides: Partial<{
+ name: string;
+ full_name: string;
+ ownerLogin: string;
+ ownerType: string;
+ fork: boolean;
+}> = {}) {
+ const ownerLogin = overrides.ownerLogin ?? "octo";
+ const ownerType = overrides.ownerType ?? "User";
return {
- name: "demo",
- full_name: "octo/demo",
- html_url: "https://github.com/octo/demo",
- clone_url: "https://github.com/octo/demo.git",
- owner: { login: "octo", type: "User" },
+ name: overrides.name ?? "demo",
+ full_name: overrides.full_name ?? `${ownerLogin}/${overrides.name ?? "demo"}`,
+ html_url: `https://github.com/${ownerLogin}/${overrides.name ?? "demo"}`,
+ clone_url: `https://github.com/${ownerLogin}/${overrides.name ?? "demo"}.git`,
+ owner: { login: ownerLogin, type: ownerType },
private: false,
- fork: false,
+ fork: overrides.fork ?? false,
has_issues: true,
archived: false,
size: 1,
@@ -23,11 +31,11 @@ function makeRepo() {
};
}
-function makeOctokit() {
+function makeOctokit(reposToReturn?: ReturnType[]) {
let captured: Record | null = null;
const paginate = mock(async (_method: unknown, options?: Record) => {
captured = options ?? null;
- return [makeRepo()];
+ return reposToReturn ?? [makeRepo()];
});
return {
octokit: {
@@ -98,3 +106,61 @@ describe("getGithubRepositories - affiliation", () => {
}
});
});
+
+describe("getGithubRepositories - skipPersonalRepos", () => {
+ const personalRepo = makeRepo({ name: "my-lib", ownerLogin: "octo", ownerType: "User" });
+ const orgRepo = makeRepo({ name: "org-lib", ownerLogin: "my-org", ownerType: "Organization" });
+ const otherUserRepo = makeRepo({ name: "collab-lib", ownerLogin: "other-user", ownerType: "User" });
+
+ test("default false — keeps all repos including personal", async () => {
+ const { octokit } = makeOctokit([personalRepo, orgRepo]);
+ const repos = await getGithubRepositories({
+ octokit,
+ config: { githubConfig: { owner: "octo", skipPersonalRepos: false } as any },
+ });
+ expect(repos.map((r) => r.name)).toContain("my-lib");
+ expect(repos.map((r) => r.name)).toContain("org-lib");
+ });
+
+ test("skipPersonalRepos=true — drops repos owned by authenticated user", async () => {
+ const { octokit } = makeOctokit([personalRepo, orgRepo]);
+ const repos = await getGithubRepositories({
+ octokit,
+ config: { githubConfig: { owner: "octo", skipPersonalRepos: true } as any },
+ });
+ expect(repos.map((r) => r.name)).not.toContain("my-lib");
+ expect(repos.map((r) => r.name)).toContain("org-lib");
+ });
+
+ test("skipPersonalRepos=true — keeps repos owned by other users (collaborator repos)", async () => {
+ const { octokit } = makeOctokit([personalRepo, orgRepo, otherUserRepo]);
+ const repos = await getGithubRepositories({
+ octokit,
+ config: { githubConfig: { owner: "octo", skipPersonalRepos: true } as any },
+ });
+ expect(repos.map((r) => r.name)).not.toContain("my-lib");
+ expect(repos.map((r) => r.name)).toContain("org-lib");
+ expect(repos.map((r) => r.name)).toContain("collab-lib");
+ });
+
+ test("skipPersonalRepos=true with no owner configured — keeps all repos (safe fallback)", async () => {
+ const { octokit } = makeOctokit([personalRepo, orgRepo]);
+ const repos = await getGithubRepositories({
+ octokit,
+ config: { githubConfig: { owner: "", skipPersonalRepos: true } as any },
+ });
+ // Empty owner means we can't identify the user, so nothing should be dropped
+ expect(repos.map((r) => r.name)).toContain("my-lib");
+ expect(repos.map((r) => r.name)).toContain("org-lib");
+ });
+
+ test("skipPersonalRepos=true — unset (undefined) behaves like false", async () => {
+ const { octokit } = makeOctokit([personalRepo, orgRepo]);
+ const repos = await getGithubRepositories({
+ octokit,
+ config: { githubConfig: { owner: "octo" } as any },
+ });
+ expect(repos.map((r) => r.name)).toContain("my-lib");
+ expect(repos.map((r) => r.name)).toContain("org-lib");
+ });
+});
diff --git a/src/lib/github.ts b/src/lib/github.ts
index a1507e3..1375ab3 100644
--- a/src/lib/github.ts
+++ b/src/lib/github.ts
@@ -263,10 +263,21 @@ export async function getGithubRepositories({
);
const skipForks = config.githubConfig?.skipForks ?? false;
+ const skipPersonalRepos = config.githubConfig?.skipPersonalRepos ?? false;
+ // The authenticated user's login — used to identify personally-owned repos
+ const authenticatedUserLogin = config.githubConfig?.owner ?? "";
const filteredRepos = repos.filter((repo) => {
const isForkAllowed = !skipForks || !repo.fork;
- return isForkAllowed;
+ // When skipPersonalRepos is true, drop repos owned by the authenticated user
+ // (owner.type === "User" and owner.login matches the configured GitHub username).
+ // Org repos have owner.type === "Organization" so they are always kept.
+ const isPersonalRepo =
+ skipPersonalRepos &&
+ authenticatedUserLogin.length > 0 &&
+ repo.owner.login === authenticatedUserLogin &&
+ repo.owner.type === "User";
+ return isForkAllowed && !isPersonalRepo;
});
return filteredRepos.map((repo) => ({
diff --git a/src/lib/utils/config-mapper.test.ts b/src/lib/utils/config-mapper.test.ts
index cb7de4e..8799550 100644
--- a/src/lib/utils/config-mapper.test.ts
+++ b/src/lib/utils/config-mapper.test.ts
@@ -124,3 +124,37 @@ test("githubConfigSchema parses includeCollaboratorRepos with true default", ()
});
expect(parsed.includeCollaboratorRepos).toBe(true);
});
+
+test("skipPersonalRepos defaults to false in githubConfigSchema", () => {
+ const parsed = githubConfigSchema.parse({
+ owner: "octo",
+ type: "personal",
+ token: "",
+ });
+ expect(parsed.skipPersonalRepos).toBe(false);
+});
+
+test("skipPersonalRepos round-trips UI -> DB -> UI when true", () => {
+ const ui = buildMinimalUiConfigs();
+ const advancedWithSkip: AdvancedOptions = { ...ui.advancedOptions, skipPersonalRepos: true };
+ const db = mapUiToDbConfig(ui.githubConfig, ui.giteaConfig, ui.mirrorOptions, advancedWithSkip);
+ expect(db.githubConfig.skipPersonalRepos).toBe(true);
+
+ const roundTripped = mapDbToUiConfig({ githubConfig: db.githubConfig, giteaConfig: db.giteaConfig });
+ expect(roundTripped.advancedOptions.skipPersonalRepos).toBe(true);
+});
+
+test("skipPersonalRepos round-trips UI -> DB -> UI when false", () => {
+ const ui = buildMinimalUiConfigs();
+ const advancedWithSkip: AdvancedOptions = { ...ui.advancedOptions, skipPersonalRepos: false };
+ const db = mapUiToDbConfig(ui.githubConfig, ui.giteaConfig, ui.mirrorOptions, advancedWithSkip);
+ expect(db.githubConfig.skipPersonalRepos).toBe(false);
+
+ const roundTripped = mapDbToUiConfig({ githubConfig: db.githubConfig, giteaConfig: db.giteaConfig });
+ expect(roundTripped.advancedOptions.skipPersonalRepos).toBe(false);
+});
+
+test("DB row missing skipPersonalRepos defaults to false on read", () => {
+ const ui = mapDbToUiConfig({ githubConfig: { owner: "octo", token: "" } });
+ expect(ui.advancedOptions.skipPersonalRepos).toBe(false);
+});
diff --git a/src/lib/utils/config-mapper.ts b/src/lib/utils/config-mapper.ts
index af8bb53..d2c9921 100644
--- a/src/lib/utils/config-mapper.ts
+++ b/src/lib/utils/config-mapper.ts
@@ -71,6 +71,7 @@ export function mapUiToDbConfig(
// Advanced options
starredCodeOnly: advancedOptions.starredCodeOnly,
autoMirrorStarred: advancedOptions.autoMirrorStarred ?? false,
+ skipPersonalRepos: advancedOptions.skipPersonalRepos ?? false,
};
// Map Gitea config to match database schema
@@ -194,6 +195,7 @@ export function mapDbToUiConfig(dbConfig: any): {
// Support both old (skipStarredIssues) and new (starredCodeOnly) field names for backward compatibility
starredCodeOnly: dbConfig.githubConfig?.starredCodeOnly ?? (dbConfig.githubConfig as any)?.skipStarredIssues ?? false,
autoMirrorStarred: dbConfig.githubConfig?.autoMirrorStarred ?? false,
+ skipPersonalRepos: dbConfig.githubConfig?.skipPersonalRepos ?? false,
};
return {
diff --git a/src/types/config.ts b/src/types/config.ts
index e552564..c57828d 100644
--- a/src/types/config.ts
+++ b/src/types/config.ts
@@ -86,6 +86,7 @@ export interface AdvancedOptions {
skipForks: boolean;
starredCodeOnly: boolean;
autoMirrorStarred?: boolean;
+ skipPersonalRepos?: boolean;
}
export interface SaveConfigApiRequest {