diff --git a/GitWeb/src/main/gitProcess.ts b/GitWeb/src/main/gitProcess.ts index f2156dd..4b28f97 100644 --- a/GitWeb/src/main/gitProcess.ts +++ b/GitWeb/src/main/gitProcess.ts @@ -1,7 +1,7 @@ import { spawn, type ChildProcess } from 'child_process' import { shell } from 'electron' -import { lstat, readFile, rm, writeFile, mkdir, access, realpath, stat } from 'fs/promises' -import { join, dirname, basename, resolve, sep, normalize } from 'path' +import { lstat, readFile, rm, rmdir, writeFile, mkdir, access, realpath, stat } from 'fs/promises' +import { join, dirname, basename, resolve, sep, normalize, toNamespacedPath } from 'path' import type { BranchDeleteCheck, BranchDeletionResult, @@ -323,6 +323,8 @@ export async function cloneRepository( const folderName = basename(destination) const extraEnv = await buildAuthEnv(trimmedUrl) const handleStderr = onProgress ? createCloneProgressParser(destination, onProgress) : undefined + // Git runs from the parent, so it has to exist (e.g. a new account's folder). + const firstCreated = await mkdir(parent, { recursive: true }) let result: GitResult try { @@ -345,13 +347,35 @@ export async function cloneRepository( if (cancelledClones.has(key)) { cancelledClones.delete(key) if (!preexisting) await removePartialClone(destination) + await removeCreatedFolders(parent, firstCreated) return { status: 'cancelled' } } - throwOnFailure(result, ['clone', trimmedUrl]) + try { + throwOnFailure(result, ['clone', trimmedUrl]) + } catch (err) { + await removeCreatedFolders(parent, firstCreated) + throw err + } return { status: 'ok' } } +// Undoes the mkdir of a clone that didn't happen. rmdir only takes empty +// folders, so anything else that landed in there in the meantime stays. +async function removeCreatedFolders(deepest: string, firstCreated: string | undefined): Promise { + if (!firstCreated) return + let dir = resolve(deepest) + try { + // On Windows mkdir reports the created folder in its `\\?\C:\…` form. + while (toNamespacedPath(dir).length >= firstCreated.length) { + await rmdir(dir) + dir = dirname(dir) + } + } catch { + // Not empty or still held open: leaving an empty folder behind is harmless. + } +} + // On Windows child.kill() terminates only git.exe, orphaning the transport // helper it spawned (git-remote-https), which keeps holding the partial clone's // files open — taskkill /T takes the whole tree down. diff --git a/GitWeb/src/main/ipc.ts b/GitWeb/src/main/ipc.ts index 136c902..15872e7 100644 --- a/GitWeb/src/main/ipc.ts +++ b/GitWeb/src/main/ipc.ts @@ -99,6 +99,8 @@ import { setRepoGroupCollapsed } from './recentRepositories' import { scanForRepositories } from './repoScan' +import { planReorganization, reorganizeRepositories } from './repoOrganize' +import type { RepoMove } from '../shared/repoOrganize' import { assertNoBlockedSensitive, scanHistory, scanSensitive, type SensitiveScanScope } from './sensitiveScan' import { createInitialCommit, publishRepository } from './publishRepository' import { assertAgentMayRead, redactForAi } from './aiRedaction' @@ -409,6 +411,10 @@ export function registerIpcHandlers(): void { ) => githubApi.submitPullRequestReview(host, owner, repo, number, event, body, commitId, comments) ) + handle('repoOrganize:plan', (_e, rootPath: string) => planReorganization(rootPath)) + handle('repoOrganize:apply', (_e, rootPath: string, confirmedMoves: RepoMove[]) => + reorganizeRepositories(rootPath, confirmedMoves) + ) handle('recentRepos:list', () => listRecentRepositories()) handle('recentRepos:add', (_e, path: string) => addRecentRepository(path)) handle('recentRepos:addMany', (_e, paths: string[]) => addRecentRepositories(paths)) diff --git a/GitWeb/src/main/recentRepositories.ts b/GitWeb/src/main/recentRepositories.ts index 1ff8963..0c2abf4 100644 --- a/GitWeb/src/main/recentRepositories.ts +++ b/GitWeb/src/main/recentRepositories.ts @@ -1,6 +1,6 @@ import { existsSync } from 'fs' import { randomUUID } from 'crypto' -import { basename, resolve } from 'path' +import { basename, isAbsolute, relative, resolve } from 'path' import { readJson, updateJson } from './appDataStore' import type { RecentReposData, RecentRepository, RepoGroup } from '../shared/git' @@ -129,6 +129,22 @@ export function addRecentRepositories(paths: string[]): Promise }) } +/** Repositories that changed folder keep their place in the list and in their group. */ +export function moveRecentRepositories(moves: { from: string; to: string }[]): Promise { + return mutate((file) => ({ + ...file, + repos: file.repos.map((repo) => { + for (const move of moves) { + const suffix = relative(canonicalPath(move.from), repo.path) + if (suffix === '..' || suffix.startsWith('../') || suffix.startsWith('..\\') || isAbsolute(suffix)) continue + const path = resolve(move.to, suffix) + return { ...repo, name: basename(path), path } + } + return repo + }) + })) +} + export function removeRecentRepository(path: string): Promise { return mutate((file) => ({ ...file, repos: file.repos.filter((r) => !samePath(r.path, canonicalPath(path))) })) } diff --git a/GitWeb/src/main/repoOrganize.ts b/GitWeb/src/main/repoOrganize.ts new file mode 100644 index 0000000..fc03452 --- /dev/null +++ b/GitWeb/src/main/repoOrganize.ts @@ -0,0 +1,309 @@ +import { randomUUID } from 'crypto' +import { existsSync, statSync } from 'fs' +import { mkdir, readFile, readdir, rename, rmdir } from 'fs/promises' +import { basename, dirname, isAbsolute, join, relative, resolve } from 'path' +import { getRemoteUrl } from './gitProcess' +import { listRecentRepositories, moveRecentRepositories } from './recentRepositories' +import { scanForRepositories } from './repoScan' +import { ownerFolders, parseRemoteUrl } from '../shared/remoteOwner' +import type { ReorganizePlan, ReorganizeResult, RepoMove, RepoMoveFailure } from '../shared/repoOrganize' + +const RENAME_ATTEMPTS = 5 +const RENAME_RETRY_MS = 300 +// Deeper than the "open a folder" scan: a GitLab repository in a subgroup lands +// at root/group/subgroup/repo, and has to be recognized as organized afterwards. +const SCAN_DEPTH = 4 + +/** The repository is safe but neither here nor there: the user has to be told where it is. */ +class StrandedRepositoryError extends Error { + constructor(readonly location: string) { + super(location) + } +} + +function pathKey(path: string): string { + return process.platform === 'win32' ? path.toLowerCase() : path +} + +function samePath(a: string, b: string): boolean { + return pathKey(a) === pathKey(b) +} + +function isInside(parent: string, child: string): boolean { + const rel = relative(parent, child) + return Boolean(rel) && !rel.startsWith('..') && !isAbsolute(rel) +} + +function hasGitDir(path: string): boolean { + try { + return statSync(join(path, '.git')).isDirectory() + } catch { + return false + } +} + +async function hasWorktreeRegistrations(gitDir: string): Promise { + try { + return (await readdir(join(gitDir, 'worktrees'))).length > 0 + } catch (err) { + // An unreadable registration directory must not be treated as safe to move. + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err + return false + } +} + +/** Deinitialized submodules keep their Git directories (and worktrees) under .git/modules. */ +async function hasModuleWorktrees(gitDir: string): Promise { + const pending = [join(gitDir, 'modules')] + while (pending.length > 0) { + const dir = pending.pop()! + let entries + try { + entries = await readdir(dir, { withFileTypes: true }) + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err + continue + } + if (entries.some((entry) => entry.name === 'HEAD' && entry.isFile())) { + if (await hasWorktreeRegistrations(dir)) return true + // Only nested modules matter here, not the object database or refs. + pending.push(join(dir, 'modules')) + } else { + // Submodule names can contain multiple path segments. + for (const entry of entries) if (entry.isDirectory()) pending.push(join(dir, entry.name)) + } + } + return false +} + +/** Worktrees also belong to nested repositories and submodules, not just the root. */ +async function hasLinkedWorktrees(path: string): Promise { + const pending = [path] + while (pending.length > 0) { + const dir = pending.pop()! + const entries = await readdir(dir, { withFileTypes: true }) + for (const entry of entries) { + const child = join(dir, entry.name) + if (entry.name === '.git') { + let gitDir = child + if (!entry.isDirectory()) { + const match = /^gitdir:\s*(.+)$/m.exec(await readFile(child, 'utf8')) + if (!match) continue + gitDir = resolve(dir, match[1].trim()) + } + // A linked checkout inside the folder has an absolute backlink too. + if ( + existsSync(join(gitDir, 'commondir')) || + await hasWorktreeRegistrations(gitDir) || + await hasModuleWorktrees(gitDir) + ) return true + } else if (entry.isDirectory()) { + // Don't follow symlinks/junctions: moving the link doesn't move its target. + // Hidden/vendor folders still matter when checking what a rename carries. + pending.push(child) + } + } + } + return false +} + +/** Folders above the target that are themselves repositories (the repository's own folder aside). */ +function repositoriesAboveTarget(root: string, move: RepoMove): string[] { + const found: string[] = [] + for (let dir = dirname(move.to); isInside(root, dir); dir = dirname(dir)) { + if (hasGitDir(dir) && !samePath(dir, move.from)) found.push(dir) + } + return found +} + +/** The base folder may already be that account's folder — no root/acme/acme/site then. */ +function targetFor(root: string, repoPath: string, folders: string[]): string { + const parent = samePath(basename(root), folders[folders.length - 1]) ? root : join(root, ...folders) + return join(parent, basename(repoPath)) +} + +/** + * Orders the moves so each one runs after the repositories in its way have left + * (say root/acme, whose account is also acme). Returns what could be scheduled: + * a repository in the way that stays put — or two waiting on each other — leaves + * the remaining moves out, so the preview never promises what can't happen. + */ +function scheduleMoves(root: string, moves: RepoMove[]): { scheduled: RepoMove[]; blocked: RepoMove[] } { + const scheduled: RepoMove[] = [] + const gone = new Set() + let pending = moves + for (;;) { + const ready = pending.filter((move) => repositoriesAboveTarget(root, move).every((dir) => gone.has(pathKey(dir)))) + if (ready.length === 0) return { scheduled, blocked: pending } + scheduled.push(...ready) + for (const move of ready) gone.add(pathKey(move.from)) + pending = pending.filter((move) => !ready.includes(move)) + } +} + +export async function planReorganization(rootPath: string): Promise { + const root = resolve(rootPath) + const plan: ReorganizePlan = { root, rootIsRepository: false, moves: [], alreadyOrganized: 0, skipped: [] } + if (hasGitDir(root)) return { ...plan, rootIsRepository: true } + + const found = await scanForRepositories(root, SCAN_DEPTH) + const remotes = await Promise.all(found.map((path) => getRemoteUrl(path).catch(() => null))) + + const candidates: RepoMove[] = [] + found.forEach((path, index) => { + const remote = remotes[index] + if (!remote) { + plan.skipped.push({ path, reason: 'noRemote' }) + return + } + const folders = ownerFolders(parseRemoteUrl(remote)?.owner ?? '') + if (!folders) { + plan.skipped.push({ path, reason: 'unknownOwner' }) + return + } + const to = targetFor(root, path, folders) + if (samePath(path, to)) plan.alreadyOrganized++ + else candidates.push({ from: path, to }) + }) + + const claimed = new Set() + const possible: RepoMove[] = [] + for (const move of candidates.sort((a, b) => a.to.localeCompare(b.to))) { + if (await hasLinkedWorktrees(move.from)) { + plan.skipped.push({ path: move.from, reason: 'hasWorktrees', target: move.to }) + } else if (claimed.has(pathKey(move.to)) || existsSync(move.to)) { + plan.skipped.push({ path: move.from, reason: 'targetExists', target: move.to }) + } else { + claimed.add(pathKey(move.to)) + possible.push(move) + } + } + + const { scheduled, blocked } = scheduleMoves(root, possible) + plan.moves = scheduled + for (const move of blocked) plan.skipped.push({ path: move.from, reason: 'ownerFolderIsRepo', target: move.to }) + return plan +} + +function sleep(ms: number): Promise { + return new Promise((done) => setTimeout(done, ms)) +} + +function isLockError(err: unknown): boolean { + const code = (err as NodeJS.ErrnoException)?.code + return code === 'EBUSY' || code === 'EPERM' || code === 'EACCES' +} + +// Antivirus, the indexer or a background `git` can hold a folder for a moment. +async function renameWithRetry(from: string, to: string): Promise { + for (let attempt = 1; ; attempt++) { + try { + await rename(from, to) + return + } catch (err) { + if (attempt >= RENAME_ATTEMPTS || !isLockError(err)) throw err + await sleep(RENAME_RETRY_MS) + } + } +} + +/** Walks up from `from`, stopping below `stop`. rmdir only takes empty folders, so anything with content stays. */ +async function removeEmptyFolders(from: string, stop: string): Promise { + try { + for (let dir = from; isInside(stop, dir); dir = dirname(dir)) await rmdir(dir) + } catch { + // Not empty, or held open: nothing to tidy up. + } +} + +async function moveRepository(move: RepoMove, root: string): Promise { + // root/acme → root/acme/acme: a folder can't be renamed into itself, so it + // steps aside first and the account's folder is created where it was. + if (isInside(move.from, move.to)) { + const aside = join(root, `.ogd-move-${randomUUID()}`) + await renameWithRetry(move.from, aside) + try { + await mkdir(dirname(move.to), { recursive: true }) + await renameWithRetry(aside, move.to) + } catch (err) { + // Only what this move created goes: the folder above the original stays, + // or the repository would have nowhere to come back to. + await removeEmptyFolders(dirname(move.to), dirname(move.from)) + try { + await renameWithRetry(aside, move.from) + } catch { + throw new StrandedRepositoryError(aside) + } + throw err + } + return + } + + await mkdir(dirname(move.to), { recursive: true }) + try { + await renameWithRetry(move.from, move.to) + } catch (err) { + await removeEmptyFolders(dirname(move.to), root) + throw err + } + await removeEmptyFolders(dirname(move.from), root) +} + +/** + * Moves what the user confirmed. The plan is worked out again here rather than + * trusted from the renderer, and a repository that no longer fits it stays put. + * One failure never stops the rest; renames are atomic, so each repository is + * either fully in the old place or fully in the new one. + */ +export async function applyReorganization( + rootPath: string, + confirmedMoves: RepoMove[] +): Promise> { + const plan = await planReorganization(rootPath) + const planned = new Map(plan.moves.map((move) => [pathKey(move.from), move])) + const result: Pick = { moved: [], failed: [] } + + const fail = (move: RepoMove, reason: RepoMoveFailure, detail?: string): void => { + result.failed.push({ ...move, reason, detail }) + } + + const confirmed = new Set() + for (const move of confirmedMoves) { + const key = pathKey(resolve(move.from)) + const current = planned.get(key) + if (!current || !samePath(current.to, resolve(move.to))) fail(move, 'planChanged') + else confirmed.add(key) + } + + for (const move of plan.moves) { + if (!confirmed.has(pathKey(move.from))) continue + // Earlier moves may have failed: what looked clear in the plan may not be. + if (existsSync(move.to)) fail(move, 'targetExists') + else if (repositoriesAboveTarget(plan.root, move).length > 0) fail(move, 'ownerFolderIsRepo') + else { + try { + await moveRepository(move, plan.root) + result.moved.push(move) + } catch (err) { + if (err instanceof StrandedRepositoryError) fail(move, 'stranded', err.location) + else if (isLockError(err)) fail(move, 'inUse') + else fail(move, 'other', err instanceof Error ? err.message : String(err)) + } + } + } + return result +} + +/** The folders have already changed when the recents are written, so that failing can't hide what moved. */ +export async function reorganizeRepositories(rootPath: string, confirmedMoves: RepoMove[]): Promise { + const result = await applyReorganization(rootPath, confirmedMoves) + try { + return { ...result, recent: await moveRecentRepositories(result.moved) } + } catch (err) { + return { + ...result, + recent: await listRecentRepositories(), + recentError: err instanceof Error ? err.message : String(err) + } + } +} diff --git a/GitWeb/src/main/repoScan.ts b/GitWeb/src/main/repoScan.ts index 7008da6..05219da 100644 --- a/GitWeb/src/main/repoScan.ts +++ b/GitWeb/src/main/repoScan.ts @@ -5,7 +5,7 @@ const MAX_SCAN_DEPTH = 2 const MAX_RESULTS = 200 const SKIP_DIR_NAMES = new Set(['node_modules', 'dist', 'build', 'out', 'venv', '__pycache__', 'vendor']) -async function scanLevel(dir: string, depth: number, results: string[]): Promise { +async function scanLevel(dir: string, depth: number, maxDepth: number, results: string[]): Promise { if (results.length >= MAX_RESULTS) return let entries try { @@ -25,10 +25,10 @@ async function scanLevel(dir: string, depth: number, results: string[]): Promise subDirs.push(entry.name) } - if (depth >= MAX_SCAN_DEPTH) return + if (depth >= maxDepth) return for (const name of subDirs) { if (results.length >= MAX_RESULTS) return - await scanLevel(join(dir, name), depth + 1, results) + await scanLevel(join(dir, name), depth + 1, maxDepth, results) } } @@ -38,7 +38,7 @@ async function scanLevel(dir: string, depth: number, results: string[]): Promise * Stops descending as soon as it finds a `.git` folder, so it returns * top-level repos rather than every nested submodule. */ -export async function scanForRepositories(rootPath: string): Promise { +export async function scanForRepositories(rootPath: string, maxDepth = MAX_SCAN_DEPTH): Promise { const results: string[] = [] let entries try { @@ -52,7 +52,7 @@ export async function scanForRepositories(rootPath: string): Promise { ) for (const entry of subDirs) { if (results.length >= MAX_RESULTS) break - await scanLevel(join(rootPath, entry.name), 1, results) + await scanLevel(join(rootPath, entry.name), 1, maxDepth, results) } return results } diff --git a/GitWeb/src/main/settings.ts b/GitWeb/src/main/settings.ts index b1f8581..375ac66 100644 --- a/GitWeb/src/main/settings.ts +++ b/GitWeb/src/main/settings.ts @@ -55,7 +55,9 @@ function normalizeSettings(stored: Partial): AppSettings { ? stored.sensitiveCheckMode : DEFAULTS.sensitiveCheckMode, cloneBaseFolders: sanitizeCloneBaseFolders(stored.cloneBaseFolders), - lastCloneBaseFolder + lastCloneBaseFolder, + cloneOrganizeByOwner: + typeof stored.cloneOrganizeByOwner === 'boolean' ? stored.cloneOrganizeByOwner : DEFAULTS.cloneOrganizeByOwner } } diff --git a/GitWeb/src/preload/index.d.ts b/GitWeb/src/preload/index.d.ts index 89373f8..1abf7fc 100644 --- a/GitWeb/src/preload/index.d.ts +++ b/GitWeb/src/preload/index.d.ts @@ -1,3 +1,4 @@ +import type { ReorganizePlan, ReorganizeResult, RepoMove } from '../shared/repoOrganize' import type { BranchDeletionResult, BranchInfo, @@ -248,6 +249,12 @@ export interface Api { comments?: { path: string; body: string; line: number }[] ) => Promise } + repoOrganize: { + /** Works out where each repository under the folder would go. Moves nothing. */ + plan: (rootPath: string) => Promise + /** Only moves repositories whose source and destination still match the confirmed preview. */ + apply: (rootPath: string, confirmedMoves: RepoMove[]) => Promise + } recentRepos: { list: () => Promise add: (path: string) => Promise diff --git a/GitWeb/src/preload/index.ts b/GitWeb/src/preload/index.ts index 6376027..cd8e578 100644 --- a/GitWeb/src/preload/index.ts +++ b/GitWeb/src/preload/index.ts @@ -1,4 +1,5 @@ import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron' +import type { ReorganizePlan, ReorganizeResult, RepoMove } from '../shared/repoOrganize' import type { BranchDeletionResult, BranchInfo, @@ -340,6 +341,11 @@ const api = { ): Promise => invoke('github:submitPullRequestReview', host, owner, repo, number, event, body, commitId, comments) }, + repoOrganize: { + plan: (rootPath: string): Promise => invoke('repoOrganize:plan', rootPath), + apply: (rootPath: string, confirmedMoves: RepoMove[]): Promise => + invoke('repoOrganize:apply', rootPath, confirmedMoves) + }, recentRepos: { list: (): Promise => invoke('recentRepos:list'), add: (path: string): Promise => invoke('recentRepos:add', path), diff --git a/GitWeb/src/renderer/src/browserApiStub.ts b/GitWeb/src/renderer/src/browserApiStub.ts index 3baca58..4b4f6a8 100644 --- a/GitWeb/src/renderer/src/browserApiStub.ts +++ b/GitWeb/src/renderer/src/browserApiStub.ts @@ -551,6 +551,10 @@ function installBrowserApiStub(): void { throw new Error(GITHUB_UNAVAILABLE) } }, + repoOrganize: { + plan: async (root) => ({ root, rootIsRepository: false, moves: [], alreadyOrganized: 0, skipped: [] }), + apply: async () => ({ moved: [], failed: [], recent: recentData() }) + }, recentRepos: { list: async () => recentData(), add: async (path) => { diff --git a/GitWeb/src/renderer/src/components/CloneModal.tsx b/GitWeb/src/renderer/src/components/CloneModal.tsx index 17ac43e..7965767 100644 --- a/GitWeb/src/renderer/src/components/CloneModal.tsx +++ b/GitWeb/src/renderer/src/components/CloneModal.tsx @@ -5,14 +5,20 @@ import { useRepoStore } from '../store/repoStore' import { useGithubAuthStore } from '../store/githubAuthStore' import { useAppSettingsStore } from '../store/appSettingsStore' import type { GitHubRepoSummary, GitHubUser } from '../lib/github' +import { parseRemoteUrl } from '../../../shared/remoteOwner' import { useFocusTrap } from '../lib/useFocusTrap' import { basename, dirname, joinPath, pathsEqual } from '../lib/path' import { + cloneParentFolder, collectCloneBaseFolders, + destinationForNewBase, + destinationForPlacement, destinationForRepo, destinationWithBase, + placementPath, rememberCloneBaseFolder, - repoNameFromCloneUrl + repoNameFromCloneUrl, + type ClonePlacement } from '../lib/cloneDestination' import GithubLoginModal from './GithubLoginModal' import CloneDestinationField from './CloneDestinationField' @@ -86,32 +92,6 @@ const LANGUAGE_COLORS: Record = { Astro: '#ff5a03' } -interface ParsedRemote { - host: string - owner: string - name: string -} - -function parseRemoteUrl(raw: string): ParsedRemote | null { - const url = raw.trim() - if (!url) return null - const ssh = /^(?:ssh:\/\/)?[\w.-]+@([^:/\s]+)[:/](.+?)(?:\.git)?\/?$/.exec(url) - if (ssh) { - const segments = ssh[2].split('/').filter(Boolean) - if (segments.length < 2) return null - return { host: ssh[1], owner: segments.slice(0, -1).join('/'), name: segments[segments.length - 1] } - } - try { - const parsed = new URL(url) - if (!/^(https?|git):$/.test(parsed.protocol)) return null - const segments = parsed.pathname.replace(/\.git\/?$/, '').split('/').filter(Boolean) - if (segments.length < 2) return null - return { host: parsed.host, owner: segments.slice(0, -1).join('/'), name: segments[segments.length - 1] } - } catch { - return null - } -} - /* ── Pieces ─────────────────────────────────────────────────────────────── */ function Monogram({ owner, lane = ownerLane(owner), size = 36 }: { owner: string; lane?: number; size?: number }): JSX.Element { @@ -418,7 +398,9 @@ function CloneModal({ open, onClose }: CloneModalProps): JSX.Element | null { const [cloneDestination, setCloneDestination] = useState('') const [baseFolder, setBaseFolder] = useState('') const [appliedRepoName, setAppliedRepoName] = useState('') + const [appliedOwner, setAppliedOwner] = useState('') const [loginModalOpen, setLoginModalOpen] = useState(false) + const organizeByOwner = settings?.cloneOrganizeByOwner ?? false // Destination of the clone in flight — also the flag that puts the dialog in // its progress state, since isBusy is shared with every other store action. @@ -528,24 +510,37 @@ function CloneModal({ open, onClose }: CloneModalProps): JSX.Element | null { await updateSettings(next) } - function applyRepoName(repoName: string): void { + function currentPlacement(): ClonePlacement { + return { base: baseFolder, owner: appliedOwner, repo: appliedRepoName, byOwner: organizeByOwner } + } + + function applyRepoName(repoName: string, owner: string): void { if (!repoName) return - setCloneDestination((dest) => destinationForRepo(dest, baseFolder, appliedRepoName, repoName)) + setCloneDestination((dest) => + organizeByOwner + ? destinationForPlacement(dest, currentPlacement(), { ...currentPlacement(), repo: repoName, owner }) + : destinationForRepo(dest, baseFolder, appliedRepoName, repoName) + ) setAppliedRepoName(repoName) + setAppliedOwner(owner) } function handleSelectRepo(repo: GitHubRepoSummary): void { setCloneUrl(repo.cloneUrl) - applyRepoName(repo.name) + applyRepoName(repo.name, repo.owner) } function handleCloneUrlChange(url: string): void { setCloneUrl(url) - applyRepoName(repoNameFromCloneUrl(url)) + applyRepoName(repoNameFromCloneUrl(url), parseRemoteUrl(url)?.owner ?? '') } function handleSelectBase(folder: string): void { - setCloneDestination((dest) => destinationWithBase(dest, baseFolder, folder, appliedRepoName)) + setCloneDestination((dest) => + organizeByOwner + ? destinationForNewBase(dest, currentPlacement(), { ...currentPlacement(), base: folder }) + : destinationWithBase(dest, baseFolder, folder, appliedRepoName) + ) setBaseFolder(folder) void rememberBase(folder) } @@ -555,14 +550,18 @@ function CloneModal({ open, onClose }: CloneModalProps): JSX.Element | null { if (!path) return const repoName = appliedRepoName || repoNameFromCloneUrl(cloneUrl) setBaseFolder(path) - setCloneDestination(destinationWithBase(cloneDestination, baseFolder, path, repoName)) + setCloneDestination( + organizeByOwner + ? destinationForNewBase(cloneDestination, currentPlacement(), { ...currentPlacement(), base: path, repo: repoName }) + : destinationWithBase(cloneDestination, baseFolder, path, repoName) + ) await rememberBase(path) } function resolvedDestination(): string { - const repoName = appliedRepoName || repoNameFromCloneUrl(cloneUrl) + const repo = appliedRepoName || repoNameFromCloneUrl(cloneUrl) const destination = cloneDestination.trim() - if (repoName && destination && pathsEqual(destination, baseFolder)) return joinPath(destination, repoName) + if (repo && destination && pathsEqual(destination, baseFolder)) return placementPath({ ...currentPlacement(), repo }) return destination } @@ -572,7 +571,8 @@ function CloneModal({ open, onClose }: CloneModalProps): JSX.Element | null { if (!canClone) return const destination = resolvedDestination() const parent = dirname(destination) - if (parent) await rememberBase(parent) + // The account's folder is derived from the base — the base is what to offer next time. + if (parent) await rememberBase(pathsEqual(parent, cloneParentFolder(currentPlacement())) ? baseFolder : parent) setCloningPath(destination) try { diff --git a/GitWeb/src/renderer/src/components/ReorganizeReposDialog.tsx b/GitWeb/src/renderer/src/components/ReorganizeReposDialog.tsx new file mode 100644 index 0000000..a0f2fd4 --- /dev/null +++ b/GitWeb/src/renderer/src/components/ReorganizeReposDialog.tsx @@ -0,0 +1,236 @@ +import { useEffect, useId, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { ArrowRight, FolderOpen, FolderSync, LoaderCircle } from 'lucide-react' +import { useAppSettingsStore } from '../store/appSettingsStore' +import { useRepoStore } from '../store/repoStore' +import { rememberCloneBaseFolder } from '../lib/cloneDestination' +import { useFocusTrap } from '../lib/useFocusTrap' +import { useT } from '../lib/i18n' +import type { ReorganizePlan, ReorganizeResult } from '../../../shared/repoOrganize' + +/** Paths in the lists read better from the chosen folder down. */ +function fromRoot(path: string, root: string): string { + return path.toLowerCase().startsWith(root.toLowerCase()) ? path.slice(root.length).replace(/^[\\/]+/, '') : path +} + +function PathRow({ from, to, note }: { from: string; to?: string; note?: string }): JSX.Element { + return ( +
  • +
    + + {from} + + {to && ( + <> + + + {to} + + + )} +
    + {note &&

    {note}

    } +
  • + ) +} + +/** + * Settings → Clone: moves the repositories already on disk to + * //. Nothing moves until the preview is confirmed. + */ +export default function ReorganizeReposDialog({ open, onClose }: { open: boolean; onClose: () => void }): JSX.Element | null { + const t = useT().settings.reorganize + const common = useT().common + const titleId = useId() + const panelRef = useRef(null) + const chooseRef = useRef(null) + const settings = useAppSettingsStore((s) => s.settings) + const updateSettings = useAppSettingsStore((s) => s.updateSettings) + const reorganizeRepositories = useRepoStore((s) => s.reorganizeRepositories) + + const [root, setRoot] = useState(null) + const [plan, setPlan] = useState(null) + const [error, setError] = useState(null) + const [moving, setMoving] = useState(false) + const [result, setResult] = useState(null) + + useEffect(() => { + if (!open) return + setRoot(settings?.lastCloneBaseFolder ?? null) + setResult(null) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + useEffect(() => { + setPlan(null) + setError(null) + if (!open || !root) return + let stale = false + window.api.repoOrganize + .plan(root) + .then((next) => { + if (!stale) setPlan(next) + }) + .catch((err: unknown) => { + if (!stale) setError(err instanceof Error ? err.message : String(err)) + }) + return () => { + stale = true + } + }, [open, root]) + + function close(): void { + // Renames can't be called back halfway through. + if (!moving) onClose() + } + + useFocusTrap(panelRef, open, { onEscape: close, initialFocusRef: chooseRef }) + + if (!open) return null + + async function choose(): Promise { + const picked = await window.api.dialogs.openDirectory() + if (picked) setRoot(picked) + } + + async function confirm(): Promise { + if (!plan) return + setMoving(true) + setError(null) + try { + const outcome = await reorganizeRepositories(plan.root, plan.moves) + setResult(outcome) + // New clones go to the folder that was just organized. + if (outcome.moved.length > 0) await updateSettings(rememberCloneBaseFolder(plan.root, settings?.cloneBaseFolders ?? [])) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setMoving(false) + } + } + + const base = plan?.root ?? '' + const scanning = Boolean(root) && !plan && !error + const canMove = Boolean(plan && plan.moves.length > 0) && !moving + + return createPortal( +
    +
    e.stopPropagation()} + > +
    + + + +
    +

    + {t.title} +

    +

    {t.lead}

    +
    +
    + + {result ? ( +
    +

    {t.movedSummary(result.moved.length)}

    + {result.recentError &&

    {t.recentError(result.recentError)}

    } + {result.failed.length > 0 && ( + <> +

    {t.failedHeading(result.failed.length)}

    +
      + {result.failed.map((failure) => ( + + ))} +
    + + )} +
    + ) : ( + <> +

    {t.folder}

    +
    + + {root ?? t.noFolder} + + +
    + +
    + {scanning && ( +

    + {t.scanning} +

    + )} + {plan?.rootIsRepository &&

    {t.rootIsRepository}

    } + {plan && !plan.rootIsRepository && ( + <> +

    + {t.summary(plan.moves.length, plan.alreadyOrganized)} +

    + {plan.moves.length > 0 && ( +
      + {/* The plan comes in running order; by destination reads account by account. */} + {[...plan.moves] + .sort((a, b) => a.to.localeCompare(b.to)) + .map((move) => ( + + ))} +
    + )} + {plan.skipped.length > 0 && ( + <> +

    {t.skippedHeading(plan.skipped.length)}

    +
      + {plan.skipped.map((skip) => ( + + ))} +
    + + )} + {plan.moves.length > 0 &&

    {t.safetyNote}

    } + + )} +
    + + )} + + {error &&

    {error}

    } + + {/* Cancel always last, on the far right. */} +
    + {result ? ( + + ) : ( + <> + + + + )} +
    +
    +
    , + document.body + ) +} diff --git a/GitWeb/src/renderer/src/lib/ai/prDraft.ts b/GitWeb/src/renderer/src/lib/ai/prDraft.ts deleted file mode 100644 index 45c2760..0000000 --- a/GitWeb/src/renderer/src/lib/ai/prDraft.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { resolveActiveConfig, type AiSettings } from '../../../../shared/ai' -import { commitMessageLanguageInstruction } from '../../../../shared/aiPrompts' -import { sendChatRequest } from './providers' -import { getLocale } from '../i18n' -import type { AiLanguage } from '../../../../shared/aiPrompts' - -export interface GeneratedPrDraft { - title: string - body: string -} - -// Same JSON-with-fallback approach as commitMessage.ts's extractJson/ -// tryParseMalformedJson — kept local (not shared) since the two generators' -// prompts and field names differ enough that a shared helper would just add -// indirection for ~30 lines of parsing. -function stripCodeFence(text: string): string { - if (!text.startsWith('```')) return text - const firstLineEnd = text.indexOf('\n') - if (firstLineEnd < 0) return text - const withoutStart = text.slice(firstLineEnd + 1) - const endFence = withoutStart.lastIndexOf('```') - return endFence >= 0 ? withoutStart.slice(0, endFence).trim() : withoutStart.trim() -} - -function extractJsonObject(text: string): string { - const start = text.indexOf('{') - const end = text.lastIndexOf('}') - return start >= 0 && end > start ? text.slice(start, end + 1) : text -} - -function decodeJsonString(value: string): string { - try { - return JSON.parse(`"${value}"`) - } catch { - return value.replace(/\\n/g, '\n').replace(/\\"/g, '"') - } -} - -function tryParseMalformedJson(content: string): GeneratedPrDraft | null { - const titleMatch = content.match(/"title"\s*:\s*"((?:\\.|[^"\\])*)"/s) - const bodyMatch = content.match(/"body"\s*:\s*"((?:\\.|[^"\\])*)"/s) - if (!titleMatch && !bodyMatch) return null - - const title = titleMatch ? decodeJsonString(titleMatch[1]).trim() : '' - const body = bodyMatch ? decodeJsonString(bodyMatch[1]).trim() : '' - if (!title && !body) return null - return { title, body } -} - -function replaceBackticks(text: string): string { - return text.replace(/`/g, "'") -} - -function extractJson(text: string): GeneratedPrDraft { - const content = stripCodeFence(text.trim()) - const jsonText = extractJsonObject(content) - - const result = ((): GeneratedPrDraft => { - try { - const parsed = JSON.parse(jsonText) - return { title: String(parsed.title ?? '').trim(), body: String(parsed.body ?? '').trim() } - } catch { - const malformed = tryParseMalformedJson(content) - if (malformed) return malformed - - const lines = content.split('\n').filter((l) => l.trim()) - return { title: lines[0] ?? '', body: lines.slice(1).join('\n') } - } - })() - - return { title: replaceBackticks(result.title), body: replaceBackticks(result.body) } -} - -const KEY_CHANGES_HEADING: Record = { - 'pt-BR': 'Principais mudanças', - en: 'Key changes', - es: 'Cambios principales' -} - -function buildPrompt( - headBranch: string, - baseBranch: string, - diff: string, - language: AiLanguage -): string { - const languageInstruction = commitMessageLanguageInstruction(language) - const changesHeading = KEY_CHANGES_HEADING[language] - - return `Analyze the Git diff below (between branch "${baseBranch}" and "${headBranch}") and draft a pull request like GitHub Copilot would. - -Requirements: -- Return only valid JSON. -- JSON shape: {"title":"...","body":"..."} -- title must be a polished sentence, concise, specific, and at most 72 characters. -- If the selected language is pt-BR, start the title with a past participle verb form such as "Adicionado", "Atualizado", or "Removido", not an infinitive like "Adicionar". If it is Spanish, likewise use "Añadido", "Actualizado", or "Eliminado", not "Añadir". -- body must be markdown useful for a real PR reviewer, not a terse changelog. -- Start body with one short paragraph summarizing the change. -- Then add a blank line and a "## ${changesHeading}" heading followed by 3 to 8 bullet points with concrete changes. -- Mention important files/classes/settings only when they clarify what changed. -- Cover all meaningful changes in the diff; avoid generic wording. -- Prefer behavior and user impact over low-level implementation trivia. -- Do not invent changes that are not visible in the diff. -- Do not include reasoning, analysis notes, or tags. -- Never use backticks. Wrap file names, class names, and code identifiers in single quotes instead, e.g. 'AiRequestError' not \`AiRequestError\`. -- ${languageInstruction} -- Do not wrap the JSON in markdown. - -Branch: ${headBranch} → ${baseBranch} - -Git diff: -${diff}` -} - -export async function generatePrDraft( - settings: AiSettings, - apiKey: string, - { headBranch, baseBranch, diff }: { headBranch: string; baseBranch: string; diff: string } -): Promise { - const truncatedDiff = diff.slice(0, settings.maxDiffChars) - const prompt = buildPrompt(headBranch, baseBranch, truncatedDiff, getLocale()) - - const response = await sendChatRequest(resolveActiveConfig(settings), apiKey, [ - { role: 'system', content: 'You write clear, accurate pull request titles and descriptions.' }, - { role: 'user', content: prompt } - ]) - - return extractJson(response.content) -} diff --git a/GitWeb/src/renderer/src/lib/ai/prReview.ts b/GitWeb/src/renderer/src/lib/ai/prReview.ts index 306ec08..6f912a4 100644 --- a/GitWeb/src/renderer/src/lib/ai/prReview.ts +++ b/GitWeb/src/renderer/src/lib/ai/prReview.ts @@ -4,7 +4,7 @@ import { sendChatRequest } from './providers' import { getLocale } from '../i18n' import type { AiLanguage } from '../../../../shared/aiPrompts' -// Unlike generatePrDraft, this returns free-form markdown (rendered directly +// Unlike generateCommitMessage, this returns free-form markdown (rendered directly // via the app's Markdown component) rather than structured JSON — it's an // advisory opinion shown to the user, never parsed or used to gate anything. function buildPrompt( diff --git a/GitWeb/src/renderer/src/lib/cloneDestination.ts b/GitWeb/src/renderer/src/lib/cloneDestination.ts index 8451c29..446a3ee 100644 --- a/GitWeb/src/renderer/src/lib/cloneDestination.ts +++ b/GitWeb/src/renderer/src/lib/cloneDestination.ts @@ -1,6 +1,7 @@ import { parseGitHubRemote } from '../../../shared/githubRemote' +import { ownerFolders } from '../../../shared/remoteOwner' import { MAX_CLONE_BASE_FOLDERS } from '../../../shared/settings' -import { basename, dirname, joinPath, pathsEqual, stripTrailingSep } from './path' +import { basename, dirname, isInsidePath, joinPath, pathsEqual, stripTrailingSep } from './path' export function repoNameFromCloneUrl(url: string): string { const trimmed = url.trim() @@ -35,7 +36,13 @@ export function collectCloneBaseFolders( add(lastUsed) for (const folder of saved) add(folder) - for (const repoPath of recentRepoPaths) add(dirname(repoPath)) + // A repository inside a known base is base/account/repo: its parent is the + // account's folder, and cloning another account's repository there would nest it. + const known = [...result] + for (const repoPath of recentRepoPaths) { + const parent = dirname(repoPath) + if (!known.some((base) => isInsidePath(base, parent))) add(parent) + } return result } @@ -70,6 +77,49 @@ export function destinationForRepo( return joinPath(base, nextRepoName) } +/** Where a clone goes when the destination follows the choices in the dialog. */ +export interface ClonePlacement { + base: string + owner: string + repo: string + byOwner: boolean +} + +export function cloneParentFolder({ base, owner, byOwner }: ClonePlacement): string { + const folders = byOwner ? ownerFolders(owner) : null + if (!folders) return base + // The base already is that account's folder — don't nest C:\Git\acme\acme\repo. + if (base && pathsEqual(basename(base), folders[folders.length - 1])) return base + return folders.reduce(joinPath, base) +} + +export function placementPath(placement: ClonePlacement): string { + const parent = cloneParentFolder(placement) + return placement.repo ? joinPath(parent, placement.repo) : parent +} + +function isUntouched(dest: string, placement: ClonePlacement): boolean { + return !dest || pathsEqual(dest, placement.base) || pathsEqual(dest, placementPath(placement)) +} + +/** + * Another repository was chosen. A destination the user edited goes by the same + * rules as without accounts: a custom name stays, an edited parent is kept. + * With no base folder yet there is nowhere to put an account folder, and a + * relative path would land wherever the app happens to be running. + */ +export function destinationForPlacement(currentDest: string, previous: ClonePlacement, next: ClonePlacement): string { + if (next.base && isUntouched(currentDest.trim(), previous)) return placementPath(next) + return destinationForRepo(currentDest, next.base, previous.repo, next.repo) +} + +/** Another base folder was chosen: everything moves under it, and a custom name stays. */ +export function destinationForNewBase(currentDest: string, previous: ClonePlacement, next: ClonePlacement): string { + const dest = currentDest.trim() + if (isUntouched(dest, previous)) return placementPath(next) + return joinPath(cloneParentFolder(next), basename(dest)) +} + export function destinationWithBase( currentDest: string, previousBase: string, diff --git a/GitWeb/src/renderer/src/lib/path.ts b/GitWeb/src/renderer/src/lib/path.ts index e823408..a178be1 100644 --- a/GitWeb/src/renderer/src/lib/path.ts +++ b/GitWeb/src/renderer/src/lib/path.ts @@ -53,6 +53,17 @@ export function pathsEqual(a: string, b: string): boolean { return stripTrailingSep(a).toLowerCase() === stripTrailingSep(b).toLowerCase() } +/** One spelling per folder, as main sees it: letter case only folds for Windows paths. */ +export function pathKey(path: string): string { + const normalized = stripTrailingSep(path).replace(/\\/g, '/') + return /^[a-zA-Z]:|^\/\//.test(normalized) ? normalized.toLowerCase() : normalized +} + +export function isInsidePath(parent: string, child: string): boolean { + const prefix = pathKey(parent).replace(/\/$/, '') + return pathKey(child).startsWith(`${prefix}/`) +} + /** A repository path as an anchored .gitignore pattern: `src/.env` → `/src/.env`. */ export function toIgnorePattern(path: string): string { const normalized = path.replace(/\\/g, '/').replace(/^\.\//, '') diff --git a/GitWeb/src/renderer/src/store/repoStore.ts b/GitWeb/src/renderer/src/store/repoStore.ts index ba46d40..56df1e1 100644 --- a/GitWeb/src/renderer/src/store/repoStore.ts +++ b/GitWeb/src/renderer/src/store/repoStore.ts @@ -1,5 +1,6 @@ import { create } from 'zustand' import type { PublishRepositoryInput, PublishRepositoryResult } from '../../../shared/github' +import type { ReorganizeResult, RepoMove } from '../../../shared/repoOrganize' import { isConflicted, isHistoryRewrittenMessage, sameRepoStatus, stashBranchName } from '../../../shared/git' import type { BranchInfo, @@ -25,6 +26,7 @@ import { import { useAppSettingsStore } from './appSettingsStore' import { getT } from '../lib/i18n' import { directPushBlock } from '../lib/branchProtection' +import { isInsidePath, joinPath, pathKey } from '../lib/path' import { extractHunkPatch, parseFileDiff } from '../../../shared/patch' type ViewName = 'changes' | 'history' | 'stash' | 'pullRequests' | 'dashboard' | 'settings' | 'about' @@ -114,6 +116,7 @@ interface RepoState { clearPublishPrompt: () => void cloneRepository: (url: string, destination: string) => Promise<'ok' | 'cancelled' | 'failed'> cancelClone: (destination: string) => Promise + reorganizeRepositories: (rootPath: string, confirmedMoves: RepoMove[]) => Promise closeRepository: () => void refreshStatus: () => Promise refreshBranches: () => Promise @@ -513,6 +516,40 @@ export const useRepoStore = create((set, get) => ({ await window.api.git.cancelClone(destination) }, + reorganizeRepositories: async (rootPath, confirmedMoves) => { + const errorBefore = get().error + const result = await window.api.repoOrganize.apply(rootPath, confirmedMoves) + const follow = (path: string): string => { + const move = result.moved.find(({ from }) => pathKey(from) === pathKey(path) || isInsidePath(from, path)) + if (!move) return path + const suffix = path.replace(/\\/g, '/').slice(pathKey(move.from).length) + return joinPath(move.to, move.to.includes('\\') ? suffix.replace(/\//g, '\\') : suffix) + } + const followKeys = (record: Record): Record => + Object.fromEntries(Object.entries(record).map(([path, value]) => [follow(path), value])) + + set((state) => ({ + recentRepos: result.recent.repos, + repoGroups: result.recent.groups, + recentRepoChecks: followKeys(state.recentRepoChecks), + batchUpdateResults: followKeys(state.batchUpdateResults), + selectedRepoPaths: state.selectedRepoPaths.map(follow) + })) + + // The open repository follows in place: reopening it would leave Settings, + // and anything still in flight for the old path must be dropped. + const current = get().currentRepoPath + if (current && follow(current) !== current) { + // A background refresh that ran between the rename and now failed on the + // old path; its "folder is gone" error says nothing about the repository. + repoOpenGeneration++ + set({ currentRepoPath: follow(current), error: errorBefore }) + await window.api.settings.setLastRepositoryPath(follow(current)) + await Promise.all([get().refreshStatus(), get().refreshBranches(), get().refreshLog()]) + } + return result + }, + closeRepository: () => { repoOpenGeneration++ void window.api.settings.setLastRepositoryPath(null) diff --git a/GitWeb/src/renderer/src/views/PullRequestsView.tsx b/GitWeb/src/renderer/src/views/PullRequestsView.tsx index 9f7cdb9..3aa3cf8 100644 --- a/GitWeb/src/renderer/src/views/PullRequestsView.tsx +++ b/GitWeb/src/renderer/src/views/PullRequestsView.tsx @@ -27,7 +27,7 @@ import PostMergeActions from '../components/PostMergeActions' import { pullRequestForBranch, usePullRequestStore } from '../store/pullRequestStore' import { useRepoStore } from '../store/repoStore' import { getActiveAiConfig, isAiConfigured, useAiStore } from '../store/aiStore' -import { generatePrDraft } from '../lib/ai/prDraft' +import { generateCommitMessage } from '../lib/ai/commitMessage' import { reviewPrDraft } from '../lib/ai/prReview' import { useClickOutside } from '../lib/useClickOutside' import { NARROW_LAYOUT_QUERY, useMediaQuery } from '../lib/useMediaQuery' @@ -503,8 +503,16 @@ function NewPullRequestWizard({ const createPullRequest = usePullRequestStore((s) => s.createPullRequest) const aiSettings = useAiStore((s) => s.settings) const aiApiKey = useAiStore((s) => s.apiKey) + const loadAiSettings = useAiStore((s) => s.loadSettings) const aiReady = isAiConfigured(aiSettings, aiApiKey) const currentBranch = branches.find((b) => b.isCurrent)?.name + + // Without this the "generate with the agent" button silently stays hidden + // whenever the store has no settings yet, even with the AI fully configured. + useEffect(() => { + if (!aiSettings) void loadAiSettings() + }, [aiSettings, loadAiSettings]) + const hasUncommittedChanges = (status?.entries.length ?? 0) > 0 const messages = useT() const t = messages.pullRequests @@ -589,17 +597,16 @@ function NewPullRequestWizard({ setGenerateError(null) try { const { settings, apiKey } = await getActiveAiConfig() + const entries = useRepoStore.getState().status?.entries ?? [] const diff = hasUncommittedChanges - ? await getDraftDiff(currentRepoPath, effectiveBase, effectiveHead, useRepoStore.getState().status?.entries ?? []) + ? await getDraftDiff(currentRepoPath, effectiveBase, effectiveHead, entries) : await window.api.git.branchDiff(currentRepoPath, effectiveBase, effectiveHead) if (!diff.trim()) throw new Error(t.noDiff) - const draftResult = await generatePrDraft(settings, apiKey, { - headBranch: effectiveHead, - baseBranch: effectiveBase, - diff - }) - setTitle(draftResult.title) - setBody(draftResult.body) + // The same generator as a commit, so a PR reads like the commits it carries. + const statusText = entries.map((e) => `${e.index}${e.worktree} ${e.path}`).join('\n') + const generated = await generateCommitMessage(settings, apiKey, effectiveHead, statusText, diff) + setTitle(generated.summary) + setBody(generated.description) setFlashKey((k) => k + 1) setJustGenerated(true) window.setTimeout(() => setJustGenerated(false), 1400) diff --git a/GitWeb/src/renderer/src/views/SettingsView.tsx b/GitWeb/src/renderer/src/views/SettingsView.tsx index 93c3c9d..82a5e4f 100644 --- a/GitWeb/src/renderer/src/views/SettingsView.tsx +++ b/GitWeb/src/renderer/src/views/SettingsView.tsx @@ -5,6 +5,8 @@ import { Check, ChevronRight, FileKey, + FolderSync, + FolderTree, HeartHandshake, Info, Languages, @@ -27,6 +29,7 @@ import { DEFAULT_APP_SETTINGS, PROJECT_CO_AUTHOR_TRAILER, type ColorSchemePrefer import type { SensitiveCheckMode, SensitiveKind } from '../../../shared/sensitiveFiles' import Switch from '../components/Switch' import GithubLoginModal from '../components/GithubLoginModal' +import ReorganizeReposDialog from '../components/ReorganizeReposDialog' import AiSettingsForm from '../components/AiSettingsForm' import { AI_SETTINGS_SECTION_ID, useSettingsTabStore, type SettingsTab } from '../lib/openAiSettings' import { NARROW_LAYOUT_QUERY, useMediaQuery } from '../lib/useMediaQuery' @@ -201,6 +204,7 @@ function GeneralPanel(): JSX.Element { const updateSettings = useAppSettingsStore((s) => s.updateSettings) const setView = useRepoStore((s) => s.setView) const t = useT().settings + const [reorganizeOpen, setReorganizeOpen] = useState(false) return ( <> @@ -239,6 +243,33 @@ function GeneralPanel(): JSX.Element { +
    +

    {t.clone.heading}

    +
    + updateSettings({ cloneOrganizeByOwner: checked })} + /> +
    + + + +
    +
    {t.reorganize.rowLabel}
    +

    {t.reorganize.rowHint}

    +
    + +
    +
    + setReorganizeOpen(false)} /> +
    +

    {t.app.heading}

    diff --git a/GitWeb/src/shared/i18n/messages/settings.ts b/GitWeb/src/shared/i18n/messages/settings.ts index 4886faa..c8d4986 100644 --- a/GitWeb/src/shared/i18n/messages/settings.ts +++ b/GitWeb/src/shared/i18n/messages/settings.ts @@ -32,6 +32,52 @@ const ptBR = { 'Ativado, cada commit feito pelo app leva o OpenGitDesktop como co-autor. Deixe ligado se quiser apoiar o projeto: é assim que ele aparece no histórico dos repositórios onde é usado.', coAuthorPreview: 'Linha adicionada à mensagem:' }, + clone: { + heading: 'Clonar', + organizeByOwner: 'Organizar por conta', + organizeByOwnerHint: + 'Ao clonar, cria uma pasta para cada conta dentro da pasta escolhida: pasta › conta › repositório.' + }, + reorganize: { + rowLabel: 'Reorganizar repositórios', + rowHint: 'Move os repositórios que já estão no computador para pasta › conta › repositório. Mostra o que vai mudar antes de mover.', + open: 'Reorganizar…', + title: 'Reorganizar repositórios', + lead: 'Cada repositório vai para a pasta da conta dona do remote origin. O nome da pasta dele não muda.', + folder: 'Pasta principal', + noFolder: 'Nenhuma pasta escolhida', + chooseFolder: 'Escolher pasta…', + scanning: 'Procurando repositórios…', + rootIsRepository: 'Esta pasta é um repositório. Escolha a pasta que contém os seus repositórios.', + summary: (moves: number, organized: number) => { + const toMove = moves === 0 ? 'Nada para mover' : moves === 1 ? '1 repositório será movido' : `${moves} repositórios serão movidos` + if (organized === 0) return `${toMove}.` + return `${toMove} · ${organized === 1 ? '1 já organizado' : `${organized} já organizados`}.` + }, + skippedHeading: (n: number) => (n === 1 ? '1 não será movido' : `${n} não serão movidos`), + reason: { + noRemote: 'Sem remote origin', + unknownOwner: 'Não deu para identificar a conta pelo remote', + targetExists: 'Já existe uma pasta com esse nome no destino', + ownerFolderIsRepo: 'A pasta da conta é outro repositório', + hasWorktrees: 'Tem worktrees ligadas, que parariam de funcionar' + }, + safetyNote: + 'Commits, branches e arquivos não são alterados, só a pasta muda. Feche editores e terminais abertos nessas pastas. Atalhos e scripts de fora do app que usem o caminho antigo precisarão ser atualizados.', + move: (n: number) => (n === 1 ? 'Mover 1 repositório' : `Mover ${n} repositórios`), + moving: 'Movendo…', + movedSummary: (n: number) => (n === 1 ? '1 repositório movido.' : `${n} repositórios movidos.`), + failedHeading: (n: number) => (n === 1 ? '1 não pôde ser movido' : `${n} não puderam ser movidos`), + recentError: (detail: string) => `As pastas foram movidas, mas não consegui atualizar a lista de recentes: ${detail}`, + failure: { + inUse: 'Pasta em uso. Feche editores e terminais abertos nela e reorganize de novo', + targetExists: 'Já existe uma pasta com esse nome no destino', + ownerFolderIsRepo: 'A pasta da conta é outro repositório', + planChanged: 'A pasta mudou depois da prévia', + stranded: 'Não consegui concluir nem desfazer. O repositório está intacto, nesta pasta oculta; mova-o manualmente', + other: 'Não foi possível mover' + } + }, ai: { heading: 'IA', hint: 'Provedor, chave e modelo do assistente supervisionado.' @@ -111,6 +157,51 @@ const en: M = { 'When on, every commit made through the app lists OpenGitDesktop as a co-author. Keep it on if you want to support the project: that is how it shows up in the history of the repositories where it is used.', coAuthorPreview: 'Line added to the message:' }, + clone: { + heading: 'Clone', + organizeByOwner: 'Organize by account', + organizeByOwnerHint: + 'When cloning, creates one folder per account inside the chosen folder: folder › account › repository.' + }, + reorganize: { + rowLabel: 'Reorganize repositories', + rowHint: 'Moves the repositories already on this computer to folder › account › repository. Shows what will change before moving.', + open: 'Reorganize…', + title: 'Reorganize repositories', + lead: 'Each repository goes to the folder of the account that owns its origin remote. Its own folder name stays the same.', + folder: 'Main folder', + noFolder: 'No folder chosen', + chooseFolder: 'Choose folder…', + scanning: 'Looking for repositories…', + rootIsRepository: 'This folder is a repository. Choose the folder that contains your repositories.', + summary: (moves, organized) => { + const toMove = moves === 0 ? 'Nothing to move' : moves === 1 ? '1 repository will be moved' : `${moves} repositories will be moved` + return organized === 0 ? `${toMove}.` : `${toMove} · ${organized} already organized.` + }, + skippedHeading: (n) => (n === 1 ? '1 will not be moved' : `${n} will not be moved`), + reason: { + noRemote: 'No origin remote', + unknownOwner: 'Could not tell the account from the remote', + targetExists: 'A folder with this name already exists at the destination', + ownerFolderIsRepo: 'The account folder is another repository', + hasWorktrees: 'Has linked worktrees, which would stop working' + }, + safetyNote: + 'Commits, branches and files are not changed; only the folder does. Close editors and terminals open in these folders. Shortcuts and scripts outside the app that use the old path will need updating.', + move: (n) => (n === 1 ? 'Move 1 repository' : `Move ${n} repositories`), + moving: 'Moving…', + movedSummary: (n) => (n === 1 ? '1 repository moved.' : `${n} repositories moved.`), + failedHeading: (n) => (n === 1 ? '1 could not be moved' : `${n} could not be moved`), + recentError: (detail) => `The folders were moved, but the list of recents could not be updated: ${detail}`, + failure: { + inUse: 'Folder in use. Close editors and terminals open in it and reorganize again', + targetExists: 'A folder with this name already exists at the destination', + ownerFolderIsRepo: 'The account folder is another repository', + planChanged: 'The folder changed after the preview', + stranded: 'Could not finish or undo the move. The repository is intact, in this hidden folder; move it manually', + other: 'Could not move' + } + }, ai: { heading: 'AI', hint: 'Provider, key and model of the supervised assistant.' @@ -188,6 +279,52 @@ const es: M = { 'Activado, cada commit hecho desde la app incluye a OpenGitDesktop como coautor. Déjalo activado si quieres apoyar el proyecto: así aparece en el historial de los repositorios donde se usa.', coAuthorPreview: 'Línea añadida al mensaje:' }, + clone: { + heading: 'Clonar', + organizeByOwner: 'Organizar por cuenta', + organizeByOwnerHint: + 'Al clonar, crea una carpeta por cuenta dentro de la carpeta elegida: carpeta › cuenta › repositorio.' + }, + reorganize: { + rowLabel: 'Reorganizar repositorios', + rowHint: 'Mueve los repositorios que ya están en el equipo a carpeta › cuenta › repositorio. Muestra qué cambiará antes de mover.', + open: 'Reorganizar…', + title: 'Reorganizar repositorios', + lead: 'Cada repositorio va a la carpeta de la cuenta dueña del remote origin. El nombre de su carpeta no cambia.', + folder: 'Carpeta principal', + noFolder: 'Ninguna carpeta elegida', + chooseFolder: 'Elegir carpeta…', + scanning: 'Buscando repositorios…', + rootIsRepository: 'Esta carpeta es un repositorio. Elige la carpeta que contiene tus repositorios.', + summary: (moves, organized) => { + const toMove = moves === 0 ? 'Nada que mover' : moves === 1 ? 'Se moverá 1 repositorio' : `Se moverán ${moves} repositorios` + if (organized === 0) return `${toMove}.` + return `${toMove} · ${organized === 1 ? '1 ya organizado' : `${organized} ya organizados`}.` + }, + skippedHeading: (n) => (n === 1 ? '1 no se moverá' : `${n} no se moverán`), + reason: { + noRemote: 'Sin remote origin', + unknownOwner: 'No se pudo identificar la cuenta por el remote', + targetExists: 'Ya existe una carpeta con ese nombre en el destino', + ownerFolderIsRepo: 'La carpeta de la cuenta es otro repositorio', + hasWorktrees: 'Tiene worktrees vinculados, que dejarían de funcionar' + }, + safetyNote: + 'Los commits, ramas y archivos no se modifican, solo cambia la carpeta. Cierra los editores y terminales abiertos en esas carpetas. Los accesos directos y scripts fuera de la app que usen la ruta antigua deberán actualizarse.', + move: (n) => (n === 1 ? 'Mover 1 repositorio' : `Mover ${n} repositorios`), + moving: 'Moviendo…', + movedSummary: (n) => (n === 1 ? '1 repositorio movido.' : `${n} repositorios movidos.`), + failedHeading: (n) => (n === 1 ? '1 no se pudo mover' : `${n} no se pudieron mover`), + recentError: (detail) => `Las carpetas se movieron, pero no se pudo actualizar la lista de recientes: ${detail}`, + failure: { + inUse: 'Carpeta en uso. Cierra los editores y terminales abiertos en ella y reorganiza de nuevo', + targetExists: 'Ya existe una carpeta con ese nombre en el destino', + ownerFolderIsRepo: 'La carpeta de la cuenta es otro repositorio', + planChanged: 'La carpeta cambió después de la vista previa', + stranded: 'No se pudo terminar ni deshacer. El repositorio está intacto, en esta carpeta oculta; muévelo manualmente', + other: 'No se pudo mover' + } + }, ai: { heading: 'IA', hint: 'Proveedor, clave y modelo del asistente supervisado.' diff --git a/GitWeb/src/shared/remoteOwner.ts b/GitWeb/src/shared/remoteOwner.ts new file mode 100644 index 0000000..9e679bb --- /dev/null +++ b/GitWeb/src/shared/remoteOwner.ts @@ -0,0 +1,61 @@ +export interface ParsedRemote { + host: string + owner: string + name: string +} + +/** + * The account behind a remote path. Hosts that put routing in the path get it + * removed, since the owner decides real folders on disk: + * Azure DevOps org/proj/_git/repo, v3/org/proj/repo → org + * Bitbucket Server scm/PROJ/repo → PROJ + * Anything else keeps the whole path, so GitLab subgroups stay `a/b`. + */ +function ownerOf(host: string, segments: string[]): string { + const hostname = host.toLowerCase().split(':')[0] + const isVisualStudio = hostname.endsWith('.visualstudio.com') + const isAzure = hostname === 'dev.azure.com' || hostname.endsWith('.dev.azure.com') || isVisualStudio + if (isAzure) { + if (segments[0] === 'v3' && segments.length >= 3) return segments[1] + // org.visualstudio.com/[DefaultCollection/]proj/_git/repo has the org in the host. + if (isVisualStudio) return hostname.split('.')[0] + return segments[0] + } + const path = segments.slice(0, -1) + if (path[0] === 'scm' && path.length >= 2) return path.slice(1).join('/') + return path.join('/') +} + +function parsedRemote(host: string, path: string): ParsedRemote | null { + const segments = path.replace(/\.git\/?$/, '').split('/').filter(Boolean) + if (segments.length < 2) return null + return { host, owner: ownerOf(host, segments), name: segments[segments.length - 1] } +} + +/** Any host, unlike parseGitHubRemote. */ +export function parseRemoteUrl(raw: string): ParsedRemote | null { + const url = raw.trim() + if (!url) return null + // scp-like (git@host:owner/repo). A URL with a scheme goes to the URL parser, + // which is what tells a port from the first path segment. + const scp = /^[\w.-]+@([^:/\s]+)[:/](.+)$/.exec(url) + if (scp) return parsedRemote(scp[1], scp[2]) + try { + const parsed = new URL(url) + if (!/^(https?|git|ssh|git\+ssh):$/.test(parsed.protocol)) return null + return parsedRemote(parsed.host, parsed.pathname) + } catch { + return null + } +} + +// Owners come from a URL anyone can write, so `git@host:../../x/repo` must not +// become a path that climbs out of the base folder. GitLab subgroups (`a/b`) +// are legitimate and become nested folders. +export function ownerFolders(owner: string): string[] | null { + const segments = owner.split('/').filter(Boolean) + if (segments.length === 0) return null + // A trailing dot also covers `.` and `..`; Windows strips trailing dots and spaces. + const safe = segments.every((segment) => !/[<>:"|?*\\\x00-\x1f]|[. ]$/.test(segment)) + return safe ? segments : null +} diff --git a/GitWeb/src/shared/repoOrganize.ts b/GitWeb/src/shared/repoOrganize.ts new file mode 100644 index 0000000..b1a214c --- /dev/null +++ b/GitWeb/src/shared/repoOrganize.ts @@ -0,0 +1,30 @@ +import type { RecentReposData } from './git' + +/** Moving a repository to //. Confirmation must preserve both previewed paths. */ +export interface RepoMove { + from: string + to: string +} + +export type RepoSkipReason = 'noRemote' | 'unknownOwner' | 'targetExists' | 'ownerFolderIsRepo' | 'hasWorktrees' + +export interface ReorganizePlan { + root: string + /** Scanning inside a repository would move its nested folders around. */ + rootIsRepository: boolean + /** In the order they have to run. */ + moves: RepoMove[] + alreadyOrganized: number + skipped: { path: string; reason: RepoSkipReason; target?: string }[] +} + +/** `stranded`: couldn't finish or undo the move; `detail` says where the repository is now. */ +export type RepoMoveFailure = 'inUse' | 'targetExists' | 'ownerFolderIsRepo' | 'planChanged' | 'stranded' | 'other' + +export interface ReorganizeResult { + moved: RepoMove[] + failed: { from: string; to: string; reason: RepoMoveFailure; detail?: string }[] + recent: RecentReposData + /** The folders moved, but the list of recents couldn't be updated. */ + recentError?: string +} diff --git a/GitWeb/src/shared/settings.ts b/GitWeb/src/shared/settings.ts index 960e97f..60b24c4 100644 --- a/GitWeb/src/shared/settings.ts +++ b/GitWeb/src/shared/settings.ts @@ -26,6 +26,9 @@ export interface AppSettings { // Parent folders offered in the clone destination combobox (most recent first). cloneBaseFolders: string[] lastCloneBaseFolder: string | null + // When true, clones land in // instead of + // directly in the base folder. + cloneOrganizeByOwner: boolean } export const DEFAULT_APP_SETTINGS: AppSettings = { @@ -37,7 +40,8 @@ export const DEFAULT_APP_SETTINGS: AppSettings = { colorScheme: 'system', language: 'system', cloneBaseFolders: [], - lastCloneBaseFolder: null + lastCloneBaseFolder: null, + cloneOrganizeByOwner: false } export const MAX_CLONE_BASE_FOLDERS = 10 diff --git a/GitWeb/test/cloneDestination.test.ts b/GitWeb/test/cloneDestination.test.ts new file mode 100644 index 0000000..598f3f1 --- /dev/null +++ b/GitWeb/test/cloneDestination.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest' +import { + cloneParentFolder, + collectCloneBaseFolders, + destinationForNewBase, + destinationForPlacement, + placementPath, + type ClonePlacement +} from '../src/renderer/src/lib/cloneDestination' +import { pathKey } from '../src/renderer/src/lib/path' + +const placement = (overrides: Partial = {}): ClonePlacement => ({ + base: 'C:\\Github', + owner: 'acme', + repo: 'site', + byOwner: true, + ...overrides +}) + +describe('placementPath', () => { + it('puts the repository inside its account folder', () => { + expect(placementPath(placement())).toBe('C:\\Github\\acme\\site') + }) + + it('ignores the account when the option is off', () => { + expect(placementPath(placement({ byOwner: false }))).toBe('C:\\Github\\site') + }) + + it('falls back to the base folder when the account is unknown', () => { + expect(placementPath(placement({ owner: '' }))).toBe('C:\\Github\\site') + }) + + it('stays on the base folder before a repository is chosen', () => { + expect(placementPath(placement({ owner: '', repo: '' }))).toBe('C:\\Github') + }) + + it('nests GitLab subgroups', () => { + expect(placementPath(placement({ owner: 'group/team' }))).toBe('C:\\Github\\group\\team\\site') + }) + + it('does not repeat the account when the base already is its folder', () => { + expect(placementPath(placement({ base: 'C:\\Github\\ACME' }))).toBe('C:\\Github\\ACME\\site') + }) + + it.each(['..', '../../evil', 'a/../b', '.', 'a:b', 'a\\b', 'trailing.', 'trailing '])( + 'refuses %j as a folder, keeping the clone in the base folder', + (owner) => { + expect(cloneParentFolder(placement({ owner }))).toBe('C:\\Github') + } + ) +}) + +describe('destinationForPlacement', () => { + it('follows the account when another repository is chosen', () => { + const next = placement({ owner: 'other', repo: 'api' }) + expect(destinationForPlacement('C:\\Github\\acme\\site', placement(), next)).toBe('C:\\Github\\other\\api') + }) + + it('moves a destination that was still only the base folder', () => { + expect(destinationForPlacement('C:\\Github', placement({ owner: '', repo: '' }), placement())).toBe( + 'C:\\Github\\acme\\site' + ) + }) + + it('keeps a custom name the user typed', () => { + const next = placement({ owner: 'other', repo: 'api' }) + expect(destinationForPlacement('D:\\custom\\folder', placement(), next)).toBe('D:\\custom\\folder') + }) + + it('keeps an edited parent, but the folder follows the repository', () => { + const next = placement({ owner: 'other', repo: 'api' }) + expect(destinationForPlacement('D:\\outra\\site', placement(), next)).toBe('D:\\outra\\api') + }) + + it('never produces a relative path when there is no base folder yet', () => { + const none = placement({ base: '', owner: '', repo: '' }) + expect(destinationForPlacement('', none, { ...none, owner: 'acme', repo: 'site' })).toBe('') + }) +}) + +describe('destinationForNewBase', () => { + it('moves a destination that followed the placement', () => { + const next = placement({ base: 'D:\\Novo' }) + expect(destinationForNewBase('C:\\Github\\acme\\site', placement(), next)).toBe('D:\\Novo\\acme\\site') + }) + + it('keeps a custom name, under the new account folder', () => { + const next = placement({ base: 'D:\\Novo' }) + expect(destinationForNewBase('C:\\x\\meu-nome', placement(), next)).toBe('D:\\Novo\\acme\\meu-nome') + }) + + it('stays on the base folder before a repository is chosen', () => { + const none = placement({ owner: '', repo: '' }) + expect(destinationForNewBase('C:\\Github', none, { ...none, base: 'D:\\Novo' })).toBe('D:\\Novo') + }) +}) + +describe('collectCloneBaseFolders', () => { + it('does not offer the account folders inside a known base', () => { + const recents = ['C:\\Github\\acme\\site', 'C:\\Github\\other\\api', 'C:\\Github\\grupo\\time\\proj', 'D:\\Solto\\repo'] + expect(collectCloneBaseFolders(['C:\\Github'], null, recents)).toEqual(['C:\\Github', 'D:\\Solto']) + }) + + it('still offers a repository parent that is the base itself', () => { + expect(collectCloneBaseFolders([], 'C:\\Github', ['C:\\Github\\site'])).toEqual(['C:\\Github']) + }) + + it('offers parents when no base is known yet', () => { + expect(collectCloneBaseFolders([], null, ['C:\\Github\\site'])).toEqual(['C:\\Github']) + }) + + it('is not fooled by a folder that only starts with the same name', () => { + expect(collectCloneBaseFolders(['C:\\Git'], null, ['C:\\Github\\site'])).toEqual(['C:\\Git', 'C:\\Github']) + }) +}) + +describe('pathKey', () => { + it('names a Windows folder the same however it is spelled', () => { + expect(pathKey('C:/Git/Site/')).toBe(pathKey('c:\\git\\site')) + }) + + it('keeps letter case on case-sensitive paths', () => { + expect(pathKey('/r/Site')).not.toBe(pathKey('/r/site')) + }) +}) diff --git a/GitWeb/test/gitProcess.test.ts b/GitWeb/test/gitProcess.test.ts index a333594..d762ae2 100644 --- a/GitWeb/test/gitProcess.test.ts +++ b/GitWeb/test/gitProcess.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from 'child_process' -import { mkdtempSync, rmSync, writeFileSync } from 'fs' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => ({ shell: { trashItem: vi.fn() } })) vi.mock('../src/main/credentials', () => ({ getSecret: vi.fn(async () => null) })) -const { isCommitPublished, locateFileInHistory, parseStatusEntries, pushBranch, readGit, runGit, undoUnpublishedCommit } = await import( +const { cloneRepository, isCommitPublished, locateFileInHistory, parseStatusEntries, pushBranch, readGit, runGit, undoUnpublishedCommit } = await import( '../src/main/gitProcess' ) @@ -78,6 +78,23 @@ describe('with real repositories', () => { rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) }) + it('clone creates the folders leading to the destination', async () => { + const destination = join(root, 'acme', 'team', 'site') + await expect(cloneRepository(remote, destination)).resolves.toEqual({ status: 'ok' }) + expect(existsSync(join(destination, 'base.txt'))).toBe(true) + }) + + it('a failed clone leaves no empty folders behind, only what it created', async () => { + const missing = join(root, 'missing.git') + await expect(cloneRepository(missing, join(root, 'acme', 'team', 'site'))).rejects.toThrow() + expect(existsSync(join(root, 'acme'))).toBe(false) + expect(existsSync(root)).toBe(true) + + mkdirSync(join(root, 'kept')) + await expect(cloneRepository(missing, join(root, 'kept', 'site'))).rejects.toThrow() + expect(existsSync(join(root, 'kept'))).toBe(true) + }) + it('push keeps a local merge commit instead of flattening it', async () => { // Local: a feature branch merged into main (a real merge commit). git(local, 'checkout', '-q', '-b', 'feature') diff --git a/GitWeb/test/remoteOwner.test.ts b/GitWeb/test/remoteOwner.test.ts new file mode 100644 index 0000000..55263e0 --- /dev/null +++ b/GitWeb/test/remoteOwner.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { ownerFolders, parseRemoteUrl } from '../src/shared/remoteOwner' + +describe('parseRemoteUrl', () => { + it.each([ + ['https://github.com/acme/site.git', 'acme', 'site'], + ['git@github.com:acme/site.git', 'acme', 'site'], + ['ssh://git@github.com/acme/site.git', 'acme', 'site'], + // A port is not part of the path. + ['ssh://git@gitlab.empresa.com:2222/acme/site.git', 'acme', 'site'], + ['https://gitlab.empresa.com:8443/acme/site.git', 'acme', 'site'], + // GitLab subgroups are real folders of the account. + ['git@gitlab.com:grupo/time/projeto.git', 'grupo/time', 'projeto'], + ['https://gitlab.com/grupo/time/projeto', 'grupo/time', 'projeto'], + // Azure DevOps puts routing in the path. + ['https://dev.azure.com/org/proj/_git/repo', 'org', 'repo'], + ['https://org@dev.azure.com/org/proj/_git/repo', 'org', 'repo'], + ['git@ssh.dev.azure.com:v3/org/proj/repo', 'org', 'repo'], + ['org@vs-ssh.visualstudio.com:v3/org/proj/repo', 'org', 'repo'], + ['https://org.visualstudio.com/proj/_git/repo', 'org', 'repo'], + ['https://org.visualstudio.com/DefaultCollection/proj/_git/repo', 'org', 'repo'], + // So does Bitbucket Server. + ['https://bitbucket.empresa.com/scm/PROJ/repo.git', 'PROJ', 'repo'], + // An account that happens to be called scm is still an account. + ['https://github.com/scm/repo.git', 'scm', 'repo'] + ])('%s', (url, owner, name) => { + expect(parseRemoteUrl(url)).toMatchObject({ owner, name }) + }) + + it.each(['', 'C:\\repos\\site.git', '/srv/git/site.git', 'file:///srv/git/acme/site.git', 'https://github.com/site'])( + 'finds no owner in %j', + (url) => { + expect(parseRemoteUrl(url)).toBeNull() + } + ) +}) + +describe('ownerFolders', () => { + it('splits subgroups into folders', () => { + expect(ownerFolders('grupo/time')).toEqual(['grupo', 'time']) + }) + + it.each(['', '..', '../../fora', 'a/../b', 'a:b', 'a\\b', 'fim.', 'fim '])('refuses %j', (owner) => { + expect(ownerFolders(owner)).toBeNull() + }) +}) diff --git a/GitWeb/test/reorganizeOpenRepo.test.ts b/GitWeb/test/reorganizeOpenRepo.test.ts new file mode 100644 index 0000000..12e33d7 --- /dev/null +++ b/GitWeb/test/reorganizeOpenRepo.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ReorganizeResult, RepoMove } from '../src/shared/repoOrganize' +import type { RepoUpdateResult, RepoUpdateStatus } from '../src/shared/git' + +const apply = vi.fn<(root: string, moves: RepoMove[]) => Promise>() +const setLastRepositoryPath = vi.fn(async () => undefined) +vi.stubGlobal('window', { api: { repoOrganize: { apply }, settings: { setLastRepositoryPath } } }) + +const { useRepoStore } = await import('../src/renderer/src/store/repoStore') + +const refreshed = { refreshStatus: vi.fn(), refreshBranches: vi.fn(), refreshLog: vi.fn() } +const recent = { repos: [], groups: [] } +const approved = [{ from: 'C:\\Git\\site', to: 'C:\\Git\\acme\\site' }] + +function moved(from: string, to: string): ReorganizeResult { + return { moved: [{ from, to }], failed: [], recent } +} + +describe('the open repository during a reorganization', () => { + beforeEach(() => { + vi.clearAllMocks() + useRepoStore.setState({ + ...refreshed, currentRepoPath: 'C:\\Git\\site', error: null, + recentRepoChecks: {}, batchUpdateResults: {}, selectedRepoPaths: [] + }) + }) + + it('follows the move', async () => { + apply.mockResolvedValue(moved('C:\\Git\\site', 'C:\\Git\\acme\\site')) + await useRepoStore.getState().reorganizeRepositories('C:\\Git', approved) + expect(apply).toHaveBeenCalledWith('C:\\Git', approved) + expect(useRepoStore.getState().currentRepoPath).toBe('C:\\Git\\acme\\site') + expect(setLastRepositoryPath).toHaveBeenCalledWith('C:\\Git\\acme\\site') + expect(refreshed.refreshStatus).toHaveBeenCalled() + }) + + it('follows the move when it was opened with a differently spelled path', async () => { + useRepoStore.setState({ currentRepoPath: 'C:/Git/SITE/' }) + apply.mockResolvedValue(moved('C:\\Git\\site', 'C:\\Git\\acme\\site')) + await useRepoStore.getState().reorganizeRepositories('C:\\Git', approved) + expect(useRepoStore.getState().currentRepoPath).toBe('C:\\Git\\acme\\site') + }) + + it('does not mistake another folder on a case-sensitive system', async () => { + useRepoStore.setState({ currentRepoPath: '/r/site' }) + apply.mockResolvedValue(moved('/r/Site', '/r/acme/Site')) + await useRepoStore.getState().reorganizeRepositories('/r', [{ from: '/r/Site', to: '/r/acme/Site' }]) + expect(useRepoStore.getState().currentRepoPath).toBe('/r/site') + }) + + it('drops the error of a refresh that ran on the old path while it moved', async () => { + apply.mockImplementation(async () => { + useRepoStore.setState({ error: 'A pasta não existe mais' }) + return moved('C:\\Git\\site', 'C:\\Git\\acme\\site') + }) + await useRepoStore.getState().reorganizeRepositories('C:\\Git', approved) + expect(useRepoStore.getState().error).toBeNull() + }) + + it('stays put when another repository moved', async () => { + useRepoStore.setState({ error: 'erro anterior' }) + apply.mockResolvedValue(moved('C:\\Git\\api', 'C:\\Git\\acme\\api')) + await useRepoStore.getState().reorganizeRepositories('C:\\Git', [{ from: 'C:\\Git\\api', to: 'C:\\Git\\acme\\api' }]) + expect(useRepoStore.getState().currentRepoPath).toBe('C:\\Git\\site') + expect(useRepoStore.getState().error).toBe('erro anterior') + expect(setLastRepositoryPath).not.toHaveBeenCalled() + expect(refreshed.refreshStatus).not.toHaveBeenCalled() + }) + + it('follows an open descendant and remaps selected and cached paths without matching siblings', async () => { + const child = 'C:/Git/SITE/nested/Child/' + const destination = 'C:\\Git\\acme\\site\\nested\\Child' + const sibling = 'C:\\Git\\site-other\\child' + const check: RepoUpdateStatus = { + state: 'upToDate', message: '', branch: 'main', defaultBranch: 'main', + behindDefault: 0, aheadOfDefault: 0, hasLocalDefault: true, + localDefaultBehind: 0, localDefaultAhead: 0, ahead: 0, behind: 0, + localChanges: 0, canFastForward: false + } + const batch: RepoUpdateResult = { outcome: 'alreadyUpToDate', message: '', commitsPulled: 0 } + useRepoStore.setState({ + currentRepoPath: child, view: 'settings', commitSummary: 'keep this draft', + selectedRepoPaths: [child, sibling], + recentRepoChecks: { [child]: check, [sibling]: check }, + batchUpdateResults: { [child]: batch, [sibling]: batch } + }) + apply.mockResolvedValue(moved(approved[0].from, approved[0].to)) + + await useRepoStore.getState().reorganizeRepositories('C:\\Git', approved) + + const state = useRepoStore.getState() + expect(state.currentRepoPath).toBe(destination) + expect(state.selectedRepoPaths).toEqual([destination, sibling]) + expect(state.recentRepoChecks).toEqual({ [destination]: check, [sibling]: check }) + expect(state.batchUpdateResults).toEqual({ [destination]: batch, [sibling]: batch }) + expect(state.view).toBe('settings') + expect(state.commitSummary).toBe('keep this draft') + expect(setLastRepositoryPath).toHaveBeenCalledWith(destination) + expect(refreshed.refreshStatus).toHaveBeenCalled() + expect(refreshed.refreshBranches).toHaveBeenCalled() + expect(refreshed.refreshLog).toHaveBeenCalled() + }) + + it('preserves descendant letter case and boundaries on POSIX paths', async () => { + const moves = [{ from: '/r/Site', to: '/r/acme/Site' }] + useRepoStore.setState({ currentRepoPath: '/r/Site/Child', selectedRepoPaths: ['/r/site/child', '/r/Site-other/child'] }) + apply.mockResolvedValue(moved(moves[0].from, moves[0].to)) + + await useRepoStore.getState().reorganizeRepositories('/r', moves) + + expect(useRepoStore.getState().currentRepoPath).toBe('/r/acme/Site/Child') + expect(useRepoStore.getState().selectedRepoPaths).toEqual(['/r/site/child', '/r/Site-other/child']) + }) + + it('maps each original path once when one source becomes another account folder', async () => { + const moves = [{ from: '/r/a', to: '/r/b/a' }, { from: '/r/b', to: '/r/c/b' }] + useRepoStore.setState({ currentRepoPath: '/r/a/child', selectedRepoPaths: ['/r/a/child', '/r/b/child'] }) + apply.mockResolvedValue({ moved: moves, failed: [], recent }) + + await useRepoStore.getState().reorganizeRepositories('/r', moves) + + expect(useRepoStore.getState().currentRepoPath).toBe('/r/b/a/child') + expect(useRepoStore.getState().selectedRepoPaths).toEqual(['/r/b/a/child', '/r/c/b/child']) + }) +}) diff --git a/GitWeb/test/repoOrganize.test.ts b/GitWeb/test/repoOrganize.test.ts new file mode 100644 index 0000000..c01e9b3 --- /dev/null +++ b/GitWeb/test/repoOrganize.test.ts @@ -0,0 +1,414 @@ +import { execFileSync, spawn } from 'child_process' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join, resolve } from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ shell: { trashItem: vi.fn() } })) +vi.mock('../src/main/credentials', () => ({ getSecret: vi.fn(async () => null) })) + +// Recents kept in memory, so the test exercises the real merge logic without Electron's userData. +const files = new Map() +vi.mock('../src/main/appDataStore', () => ({ + readJson: vi.fn(async (name: string, fallback: unknown) => files.get(name) ?? fallback), + writeJson: vi.fn(async (name: string, value: unknown) => void files.set(name, value)), + updateJson: vi.fn(async (name: string, fallback: unknown, updater: (current: unknown) => unknown) => { + const next = updater(files.get(name) ?? fallback) + files.set(name, next) + return next + }) +})) + +const { applyReorganization, planReorganization, reorganizeRepositories } = await import('../src/main/repoOrganize') +const { addRecentRepositories, createRepoGroup, moveRecentRepositories, setRepoGroup } = await import( + '../src/main/recentRepositories' +) + +function git(cwd: string, ...args: string[]): string { + return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() +} + +describe('reorganizing repositories', () => { + let root: string + + /** A real repository with a commit, an uncommitted file and (optionally) an origin. */ + function repo(relativePath: string, origin?: string): string { + const path = join(root, relativePath) + mkdirSync(path, { recursive: true }) + git(path, 'init', '-q', '-b', 'main') + git(path, 'config', 'user.name', 'Test') + git(path, 'config', 'user.email', 'test@example.com') + git(path, 'config', 'commit.gpgsign', 'false') + writeFileSync(join(path, 'tracked.txt'), 'tracked\n') + git(path, 'add', '.') + git(path, 'commit', '-q', '-m', 'first') + writeFileSync(join(path, 'uncommitted.txt'), 'work in progress\n') + if (origin) git(path, 'remote', 'add', 'origin', origin) + return path + } + + beforeEach(() => { + root = resolve(mkdtempSync(join(tmpdir(), 'ogd-organize-'))) + files.clear() + }) + + afterEach(() => { + rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) + }) + + it('plans without touching anything', async () => { + const site = repo('site', 'https://github.com/acme/site.git') + const plan = await planReorganization(root) + expect(plan.moves).toEqual([{ from: site, to: join(root, 'acme', 'site') }]) + expect(existsSync(site)).toBe(true) + expect(existsSync(join(root, 'acme'))).toBe(false) + }) + + it('moves each repository to its account, with history and uncommitted work intact', async () => { + const site = repo('site', 'https://github.com/acme/site.git') + const api = repo('nested/api', 'git@github.com:other/api-renamed.git') + const sha = git(site, 'rev-parse', 'HEAD') + + const result = await applyReorganization(root, [ + { from: site, to: join(root, 'acme', 'site') }, + { from: api, to: join(root, 'other', 'api') } + ]) + + expect(result.failed).toEqual([]) + const movedSite = join(root, 'acme', 'site') + // The folder keeps its own name, even when the remote's differs. + const movedApi = join(root, 'other', 'api') + expect(result.moved).toHaveLength(2) + expect(git(movedSite, 'rev-parse', 'HEAD')).toBe(sha) + expect(git(movedSite, 'status', '--porcelain')).toContain('uncommitted.txt') + expect(existsSync(movedApi)).toBe(true) + expect(existsSync(site)).toBe(false) + // The folder that only held `api` went away with it. + expect(existsSync(join(root, 'nested'))).toBe(false) + }) + + it('keeps a source folder that still has other content', async () => { + const api = repo('nested/api', 'https://github.com/acme/api.git') + writeFileSync(join(root, 'nested', 'notes.txt'), 'keep me\n') + await applyReorganization(root, [{ from: api, to: join(root, 'acme', 'api') }]) + expect(existsSync(join(root, 'nested', 'notes.txt'))).toBe(true) + }) + + it('moves only what was confirmed', async () => { + const site = repo('site', 'https://github.com/acme/site.git') + const api = repo('api', 'https://github.com/acme/api.git') + const result = await applyReorganization(root, [{ from: site, to: join(root, 'acme', 'site') }]) + expect(result.moved.map((move) => move.from)).toEqual([site]) + expect(existsSync(api)).toBe(true) + }) + + it('leaves alone what is already organized, and running twice changes nothing', async () => { + repo('acme/site', 'https://github.com/acme/site.git') + const api = repo('api', 'https://github.com/acme/api.git') + await applyReorganization(root, [{ from: api, to: join(root, 'acme', 'api') }]) + const plan = await planReorganization(root) + expect(plan.moves).toEqual([]) + expect(plan.alreadyOrganized).toBe(2) + }) + + it('moves a repository named after its own account into itself', async () => { + const profile = repo('acme', 'https://github.com/acme/acme.git') + const site = repo('site', 'https://github.com/acme/site.git') + const result = await applyReorganization(root, [ + { from: site, to: join(root, 'acme', 'site') }, + { from: profile, to: join(root, 'acme', 'acme') } + ]) + expect(result.failed).toEqual([]) + expect(git(join(root, 'acme', 'acme'), 'log', '--format=%s')).toBe('first') + expect(existsSync(join(root, 'acme', 'site', 'tracked.txt'))).toBe(true) + expect(existsSync(join(root, 'acme', '.git'))).toBe(false) + }) + + it('does not nest the account when the chosen folder already is its folder', async () => { + const parent = root + root = join(parent, 'acme') + repo('site', 'https://github.com/acme/site.git') + const plan = await planReorganization(root) + expect(plan.moves).toEqual([]) + expect(plan.alreadyOrganized).toBe(1) + root = parent + }) + + it.each([ + ['no origin', undefined, 'noRemote'], + ['a local path as origin', 'C:\\repos\\site.git', 'unknownOwner'], + ['an owner that climbs out of the folder', 'git@evil.com:../../fora/site.git', 'unknownOwner'] + ])('skips a repository with %s', async (_name, origin, reason) => { + const site = repo('site', origin) + const plan = await planReorganization(root) + expect(plan.moves).toEqual([]) + expect(plan.skipped).toEqual([expect.objectContaining({ path: site, reason })]) + }) + + it('skips a target that already exists, without overwriting it', async () => { + const site = repo('site', 'https://github.com/acme/site.git') + mkdirSync(join(root, 'acme', 'site'), { recursive: true }) + writeFileSync(join(root, 'acme', 'site', 'existing.txt'), 'mine\n') + + expect((await planReorganization(root)).skipped[0].reason).toBe('targetExists') + const result = await applyReorganization(root, [{ from: site, to: join(root, 'acme', 'site') }]) + expect(result.moved).toEqual([]) + expect(result.failed[0].reason).toBe('planChanged') + expect(existsSync(join(site, 'tracked.txt'))).toBe(true) + expect(existsSync(join(root, 'acme', 'site', 'existing.txt'))).toBe(true) + }) + + it('moves only one of two folders that would land on the same target', async () => { + repo('a/site', 'https://github.com/acme/site.git') + repo('b/site', 'https://github.com/acme/site.git') + const plan = await planReorganization(root) + expect(plan.moves).toHaveLength(1) + expect(plan.skipped.map((skip) => skip.reason)).toEqual(['targetExists']) + }) + + it('skips when the account folder is another repository that stays', async () => { + repo('acme', 'C:\\repos\\no-owner.git') + repo('site', 'https://github.com/acme/site.git') + const reasons = (await planReorganization(root)).skipped.map((skip) => skip.reason).sort() + expect(reasons).toEqual(['ownerFolderIsRepo', 'unknownOwner']) + }) + + it('does not promise a move blocked by a repository that was itself skipped', async () => { + const acme = repo('acme', 'https://github.com/other/acme.git') + git(acme, 'worktree', 'add', '-q', '-b', 'side', join(root, '.worktrees', 'side')) + const site = repo('site', 'https://github.com/acme/site.git') + const plan = await planReorganization(root) + expect(plan.moves).toEqual([]) + expect(plan.skipped).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: acme, reason: 'hasWorktrees' }), + expect.objectContaining({ path: site, reason: 'ownerFolderIsRepo' }) + ]) + ) + }) + + it('runs a chain of moves in the order that clears the way', async () => { + const a = repo('a', 'https://github.com/b/a.git') + const b = repo('b', 'https://github.com/c/b.git') + const c = repo('c', 'https://github.com/d/c.git') + const result = await applyReorganization(root, [ + { from: a, to: join(root, 'b', 'a') }, + { from: b, to: join(root, 'c', 'b') }, + { from: c, to: join(root, 'd', 'c') } + ]) + expect(result.failed).toEqual([]) + for (const moved of [join(root, 'b', 'a'), join(root, 'c', 'b'), join(root, 'd', 'c')]) { + expect(existsSync(join(moved, 'tracked.txt'))).toBe(true) + } + }) + + it('leaves two repositories that wait on each other where they are', async () => { + const a = repo('a', 'https://github.com/b/a.git') + const b = repo('b', 'https://github.com/a/b.git') + const plan = await planReorganization(root) + expect(plan.moves).toEqual([]) + expect(plan.skipped.map((skip) => [skip.path, skip.reason])).toEqual( + expect.arrayContaining([ + [a, 'ownerFolderIsRepo'], + [b, 'ownerFolderIsRepo'] + ]) + ) + }) + + it('finds a repository again inside a subgroup, instead of losing track of it', async () => { + const site = repo('site', 'git@gitlab.com:grupo/time/site.git') + await applyReorganization(root, [{ from: site, to: join(root, 'grupo', 'time', 'site') }]) + expect(existsSync(join(root, 'grupo', 'time', 'site', 'tracked.txt'))).toBe(true) + const plan = await planReorganization(root) + expect(plan.moves).toEqual([]) + expect(plan.alreadyOrganized).toBe(1) + }) + + it('skips a repository with linked worktrees, which record its absolute path', async () => { + const site = repo('site', 'https://github.com/acme/site.git') + git(site, 'worktree', 'add', '-q', '-b', 'side', join(root, '.worktrees', 'side')) + const plan = await planReorganization(root) + expect(plan.moves).toEqual([]) + expect(plan.skipped[0].reason).toBe('hasWorktrees') + }) + + it('skips a containing repository when a deeply nested repository has an external worktree', async () => { + const site = repo('site', 'https://github.com/acme/site.git') + const child = repo('site/vendor/.nested/a/b/child') + const worktree = join(root, '.worktrees', 'side') + git(child, 'worktree', 'add', '-q', '-b', 'side', worktree) + + const plan = await planReorganization(root) + expect(plan.moves).toEqual([]) + expect(plan.skipped).toContainEqual(expect.objectContaining({ path: site, reason: 'hasWorktrees' })) + const result = await applyReorganization(root, [{ from: site, to: join(root, 'acme', 'site') }]) + expect(result.moved).toEqual([]) + expect(git(worktree, 'status', '--porcelain')).toBe('') + expect(existsSync(join(site, 'uncommitted.txt'))).toBe(true) + }) + + it('skips a containing repository when it carries a linked checkout of another repository', async () => { + const site = repo('site', 'https://github.com/acme/site.git') + const main = repo('.external/main') + const worktree = join(site, 'nested', 'side') + git(main, 'worktree', 'add', '-q', '-b', 'side', worktree) + + const plan = await planReorganization(root) + expect(plan.moves).toEqual([]) + expect(plan.skipped).toContainEqual(expect.objectContaining({ path: site, reason: 'hasWorktrees' })) + expect(git(worktree, 'rev-parse', '--show-toplevel').replace(/\\/g, '/')).toBe(worktree.replace(/\\/g, '/')) + }) + + it('checks nested submodules without blocking a normal submodule move', async () => { + const site = repo('site', 'https://github.com/acme/site.git') + const source = repo('.external/library') + git(site, '-c', 'protocol.file.allow=always', 'submodule', 'add', '-q', source, 'vendor/library') + + const plan = await planReorganization(root) + expect(plan.moves).toHaveLength(1) + const result = await applyReorganization(root, plan.moves) + expect(result.failed).toEqual([]) + expect(git(join(root, 'acme', 'site', 'vendor', 'library'), 'log', '--format=%s')).toBe('first') + }) + + it('detects worktrees registered by a nested submodule', async () => { + const site = repo('site', 'https://github.com/acme/site.git') + const source = repo('.external/library') + git(site, '-c', 'protocol.file.allow=always', 'submodule', 'add', '-q', source, 'vendor/library') + const worktree = join(root, '.worktrees', 'library') + git(join(site, 'vendor', 'library'), 'worktree', 'add', '-q', '-b', 'side', worktree) + + const plan = await planReorganization(root) + expect(plan.moves).toEqual([]) + expect(plan.skipped).toContainEqual(expect.objectContaining({ path: site, reason: 'hasWorktrees' })) + expect(git(worktree, 'status', '--porcelain')).toBe('') + }) + + it('detects submodule worktrees even after the submodule working tree is deinitialized', async () => { + const site = repo('site', 'https://github.com/acme/site.git') + const source = repo('.external/library') + git(site, '-c', 'protocol.file.allow=always', 'submodule', 'add', '-q', source, 'vendor/library') + const worktree = join(root, '.worktrees', 'library') + git(join(site, 'vendor', 'library'), 'worktree', 'add', '-q', '-b', 'side', worktree) + git(site, 'submodule', 'deinit', '-f', '--', 'vendor/library') + + const plan = await planReorganization(root) + expect(plan.moves).toEqual([]) + expect(plan.skipped).toContainEqual(expect.objectContaining({ path: site, reason: 'hasWorktrees' })) + expect(git(worktree, 'status', '--porcelain')).toBe('') + }) + + it('refuses a chosen folder that is itself a repository', async () => { + const parent = repo('parent', 'https://github.com/acme/parent.git') + repo('parent/inner', 'https://github.com/acme/inner.git') + const plan = await planReorganization(parent) + expect(plan.rootIsRepository).toBe(true) + expect(plan.moves).toEqual([]) + }) + + it('reports a repository that changed since the preview', async () => { + const site = repo('site', 'https://github.com/acme/site.git') + const gone = join(root, 'gone') + const result = await applyReorganization(root, [ + { from: site, to: join(root, 'acme', 'site') }, + { from: gone, to: join(root, 'acme', 'gone') } + ]) + expect(result.moved).toHaveLength(1) + expect(result.failed).toEqual([expect.objectContaining({ from: gone, reason: 'planChanged' })]) + }) + + it('rejects a destination that changed after the preview, while still moving unchanged repositories', async () => { + const site = repo('site', 'https://github.com/acme/site.git') + const api = repo('api', 'https://github.com/acme/api.git') + const preview = await planReorganization(root) + const approved = preview.moves.find((move) => move.from === site)! + git(site, 'remote', 'set-url', 'origin', 'https://github.com/other/site.git') + + const result = await applyReorganization(root, preview.moves) + + expect(result.moved).toEqual([{ from: api, to: join(root, 'acme', 'api') }]) + expect(result.failed).toEqual([{ ...approved, reason: 'planChanged', detail: undefined }]) + expect(existsSync(join(site, 'uncommitted.txt'))).toBe(true) + expect(existsSync(join(root, 'other', 'site'))).toBe(false) + }) + + it('does not trust a destination supplied by the renderer', async () => { + const site = repo('site', 'https://github.com/acme/site.git') + const result = await applyReorganization(root, [{ from: site, to: join(root, '..', 'unapproved') }]) + expect(result.moved).toEqual([]) + expect(result.failed[0].reason).toBe('planChanged') + expect(existsSync(site)).toBe(true) + }) + + it('rechecks nested worktree registrations when applying the preview', async () => { + const site = repo('site', 'https://github.com/acme/site.git') + const child = repo('site/child') + const preview = await planReorganization(root) + const worktree = join(root, '.worktrees', 'side') + git(child, 'worktree', 'add', '-q', '-b', 'side', worktree) + + const result = await applyReorganization(root, preview.moves) + expect(result.moved).toEqual([]) + expect(result.failed[0].reason).toBe('planChanged') + expect(git(worktree, 'status', '--porcelain')).toBe('') + }) + + // Windows refuses to rename a folder some process has as its working directory. + it.runIf(process.platform === 'win32')('reports a folder in use, keeps going and leaves nothing half done', async () => { + const busy = repo('busy', 'https://github.com/acme/busy.git') + const site = repo('site', 'https://github.com/acme/site.git') + const holder = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 30000)'], { cwd: busy, stdio: 'ignore' }) + try { + await new Promise((done) => holder.once('spawn', done)) + const result = await applyReorganization(root, [ + { from: busy, to: join(root, 'acme', 'busy') }, + { from: site, to: join(root, 'acme', 'site') } + ]) + expect(result.failed).toEqual([expect.objectContaining({ from: busy, reason: 'inUse' })]) + expect(result.moved.map((move) => move.from)).toEqual([site]) + expect(existsSync(join(busy, 'tracked.txt'))).toBe(true) + expect(existsSync(join(root, 'acme', 'busy'))).toBe(false) + } finally { + await new Promise((done) => { + holder.once('exit', done) + holder.kill() + }) + } + }, 15000) + + it('updates the recents in place, keeping their order and group', async () => { + const site = repo('site', 'https://github.com/acme/site.git') + const api = repo('api', 'https://github.com/acme/api.git') + await addRecentRepositories([api, site]) + const data = await createRepoGroup('Work') + await setRepoGroup(site, data.groups[0].id) + + const { moved } = await applyReorganization(root, [ + { from: site, to: join(root, 'acme', 'site') }, + { from: api, to: join(root, 'acme', 'api') } + ]) + const recent = await moveRecentRepositories(moved) + + expect(recent.repos.map((entry) => entry.path)).toEqual([join(root, 'acme', 'api'), join(root, 'acme', 'site')]) + expect(recent.repos[1].groupId).toBe(data.groups[0].id) + expect(recent.repos.every((entry) => !entry.missing)).toBe(true) + }) + + it('updates descendant recents without losing their order or groups, or matching sibling prefixes', async () => { + const site = repo('site', 'https://github.com/acme/site.git') + const child = repo('site/nested/child') + const sibling = repo('site-other') + await addRecentRepositories([child, site, sibling]) + const data = await createRepoGroup('Work') + await setRepoGroup(child, data.groups[0].id) + + const result = await reorganizeRepositories(root, [{ from: site, to: join(root, 'acme', 'site') }]) + + expect(result.recent.repos.map((entry) => entry.path)).toEqual([ + join(root, 'acme', 'site', 'nested', 'child'), join(root, 'acme', 'site'), sibling + ]) + expect(result.recent.repos[0].name).toBe('child') + expect(result.recent.repos[0].groupId).toBe(data.groups[0].id) + expect(result.recent.repos.every((entry) => !entry.missing)).toBe(true) + }) +}) diff --git a/GitWeb/test/repoOrganizeFailures.test.ts b/GitWeb/test/repoOrganizeFailures.test.ts new file mode 100644 index 0000000..8f0d71d --- /dev/null +++ b/GitWeb/test/repoOrganizeFailures.test.ts @@ -0,0 +1,107 @@ +import { execFileSync } from 'child_process' +import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join, resolve } from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ shell: { trashItem: vi.fn() } })) +vi.mock('../src/main/credentials', () => ({ getSecret: vi.fn(async () => null) })) + +// Failures a real disk only produces with bad timing: a lock on the folder that +// stepped aside, and a recents file that can't be written. +const lock = { whenLeavingAside: false, alsoOnTheWayBack: false, original: '' } +vi.mock('fs/promises', async (importOriginal) => { + const real = await importOriginal() + return { + ...real, + rename: vi.fn(async (from: string, to: string) => { + const goingBack = to === lock.original + if (lock.whenLeavingAside && from.includes('.ogd-move-') && (!goingBack || lock.alsoOnTheWayBack)) { + throw Object.assign(new Error('EBUSY: resource busy'), { code: 'EBUSY' }) + } + return real.rename(from, to) + }) + } +}) + +const recents = { failWrite: false, file: undefined as unknown } +vi.mock('../src/main/appDataStore', () => ({ + readJson: vi.fn(async (_name: string, fallback: unknown) => recents.file ?? fallback), + writeJson: vi.fn(async () => undefined), + updateJson: vi.fn(async (_name: string, fallback: unknown, updater: (current: unknown) => unknown) => { + if (recents.failWrite) throw new Error('EPERM: recent-repos.json') + recents.file = updater(recents.file ?? fallback) + return recents.file + }) +})) + +const { applyReorganization, reorganizeRepositories } = await import('../src/main/repoOrganize') + +function git(cwd: string, ...args: string[]): void { + execFileSync('git', args, { cwd, stdio: 'ignore' }) +} + +describe('when moving goes wrong', () => { + let root: string + + function repo(relativePath: string, origin: string): string { + const path = join(root, relativePath) + mkdirSync(path, { recursive: true }) + git(path, 'init', '-q', '-b', 'main') + git(path, 'remote', 'add', 'origin', origin) + writeFileSync(join(path, 'work.txt'), 'uncommitted\n') + return path + } + + beforeEach(() => { + root = resolve(mkdtempSync(join(tmpdir(), 'ogd-organize-fail-'))) + Object.assign(lock, { whenLeavingAside: false, alsoOnTheWayBack: false, original: '' }) + Object.assign(recents, { failWrite: false, file: undefined }) + }) + + afterEach(() => { + rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) + }) + + it('puts a repository moving into itself back, even as the only thing in its parent folder', async () => { + const from = repo('a/b', 'https://gitlab.com/a/b/b.git') + Object.assign(lock, { whenLeavingAside: true, original: from }) + + const result = await applyReorganization(root, [{ from, to: join(root, 'a', 'b', 'b') }]) + + expect(result.moved).toEqual([]) + expect(result.failed).toEqual([expect.objectContaining({ from, reason: 'inUse' })]) + expect(existsSync(join(from, 'work.txt'))).toBe(true) + expect(readdirSync(root)).toEqual(['a']) + }, 15000) + + it('says where the repository is when it can not even be put back', async () => { + const from = repo('acme', 'https://github.com/acme/acme.git') + Object.assign(lock, { whenLeavingAside: true, alsoOnTheWayBack: true, original: from }) + + const result = await applyReorganization(root, [{ from, to: join(root, 'acme', 'acme') }]) + + const [failure] = result.failed + expect(failure.reason).toBe('stranded') + expect(failure.detail).toContain('.ogd-move-') + expect(existsSync(join(failure.detail as string, 'work.txt'))).toBe(true) + }, 15000) + + it('still reports what moved when the recents can not be saved', async () => { + const from = repo('site', 'https://github.com/acme/site.git') + recents.failWrite = true + + const result = await reorganizeRepositories(root, [{ from, to: join(root, 'acme', 'site') }]) + + expect(result.moved).toEqual([{ from, to: join(root, 'acme', 'site') }]) + expect(result.recentError).toContain('EPERM') + expect(result.recent.repos).toEqual([]) + }) + + it('reports no problem when the recents were saved', async () => { + const from = repo('site', 'https://github.com/acme/site.git') + const result = await reorganizeRepositories(root, [{ from, to: join(root, 'acme', 'site') }]) + expect(result.moved).toHaveLength(1) + expect(result.recentError).toBeUndefined() + }) +})