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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions GitWeb/src/main/gitProcess.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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<void> {
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.
Expand Down
6 changes: 6 additions & 0 deletions GitWeb/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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))
Expand Down
18 changes: 17 additions & 1 deletion GitWeb/src/main/recentRepositories.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -129,6 +129,22 @@ export function addRecentRepositories(paths: string[]): Promise<RecentReposData>
})
}

/** Repositories that changed folder keep their place in the list and in their group. */
export function moveRecentRepositories(moves: { from: string; to: string }[]): Promise<RecentReposData> {
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<RecentReposData> {
return mutate((file) => ({ ...file, repos: file.repos.filter((r) => !samePath(r.path, canonicalPath(path))) }))
}
Expand Down
Loading
Loading