branch list & mutations, tag list & mutations, more file mutations
Mpackage.json
@@ -1,5 +1,6 @@
{
"name": "hearthforge",
"version": "1.0",
"module": "src/index.tsx",
"scripts": {
"dev": "bun scripts/dev.ts",
Msrc/constants.ts
@@ -32,6 +32,8 @@ export const COMMITS_PER_PAGE = 20;
export const ISSUES_PER_PAGE = 20;
export const PATCHES_PER_PAGE = 20;
export const RELEASES_PER_PAGE = 20;
export const BRANCHES_PER_PAGE = 30;
export const TAGS_PER_PAGE = 30;
// Cache sizes
export const MAX_MD_CACHE = 50;
Msrc/routes/repos.tsx
@@ -5,9 +5,11 @@ import { fileTypeFromBuffer } from "file-type";
import { sql } from "kysely";
import config from "../config.ts";
import {
BRANCHES_PER_PAGE,
COMMITS_PER_PAGE,
paths,
REPOS_PER_PAGE,
TAGS_PER_PAGE,
VALID_REPO_NAME_RE,
} from "../constants.ts";
import { db } from "../db/index.ts";
@@ -22,15 +24,18 @@ import {
import { renderMarkdown } from "../services/markdown.ts";
import { ensureRepoRecord, repoDiskExists } from "../services/repoSync.ts";
import { html } from "../views/render.tsx";
import { BranchList } from "../views/repos/BranchList.tsx";
import { CommitDetail } from "../views/repos/CommitDetail.tsx";
import { CommitLog } from "../views/repos/CommitLog.tsx";
import { FileBlob } from "../views/repos/FileBlob.tsx";
import { FileEdit } from "../views/repos/FileEdit.tsx";
import { FileTree } from "../views/repos/FileTree.tsx";
import { NewFileForm } from "../views/repos/NewFileForm.tsx";
import { NewRepo } from "../views/repos/NewRepo.tsx";
import { RepoHome } from "../views/repos/RepoHome.tsx";
import { RepoList } from "../views/repos/RepoList.tsx";
import { RepoSettings } from "../views/repos/RepoSettings.tsx";
import { TagList } from "../views/repos/TagList.tsx";
async function getRepo(name: string, isAdmin: boolean) {
if (!repoDiskExists(name)) return null;
@@ -280,15 +285,19 @@ export const repoRoutes = new Elysia()
let readmePath: string | undefined;
let entries: Awaited<ReturnType<typeof git.lsTree>> = [];
let branches: string[] = [];
let tags: string[] = [];
if (hasContent) {
const [lsResult, branchResult, resolved] = await Promise.all([
git.lsTree(repo.name, repo.default_branch),
git.branches(repo.name),
git.resolveRef(repo.name, repo.default_branch),
]);
const [lsResult, branchResult, tagResult, resolved] =
await Promise.all([
git.lsTree(repo.name, repo.default_branch),
git.branches(repo.name),
git.tags(repo.name),
git.resolveRef(repo.name, repo.default_branch),
]);
entries = lsResult;
branches = branchResult;
tags = tagResult;
const readme = await readReadme(repo.name, repo.default_branch);
if (readme) {
const key = resolved
@@ -316,6 +325,7 @@ export const repoRoutes = new Elysia()
readmePath={readmePath}
hasContent={hasContent}
branches={branches}
tags={tags}
/>,
);
})
@@ -376,9 +386,10 @@ export const repoRoutes = new Elysia()
const resolved = await git.resolveRef(repo.name, params.ref);
if (!resolved) return new Response("Not found", { status: 404 });
const [entries, branches] = await Promise.all([
const [entries, branches, tags] = await Promise.all([
git.lsTree(repo.name, params.ref),
git.branches(repo.name),
git.tags(repo.name),
]);
const readme = await readReadme(repo.name, params.ref);
const readmeHtml = readme
@@ -396,6 +407,7 @@ export const repoRoutes = new Elysia()
subpath=""
entries={entries}
branches={branches}
tags={tags}
readmeHtml={readmeHtml}
readmePath={readme?.filename}
/>,
@@ -411,9 +423,10 @@ export const repoRoutes = new Elysia()
if (!resolved) return new Response("Not found", { status: 404 });
const subpath = decodeURIComponent(params["*"]);
const [entries, branches] = await Promise.all([
const [entries, branches, tags] = await Promise.all([
git.lsTree(repo.name, params.ref, subpath),
git.branches(repo.name),
git.tags(repo.name),
]);
if (entries.length === 0) {
// Could be a file — redirect to blob
@@ -440,6 +453,7 @@ export const repoRoutes = new Elysia()
subpath={subpath}
entries={entries}
branches={branches}
tags={tags}
readmeHtml={readmeHtml}
readmePath={readme?.filename}
/>,
@@ -452,9 +466,10 @@ export const repoRoutes = new Elysia()
if (!repo) return new Response("Not found", { status: 404 });
const filePath = decodeURIComponent(params["*"]);
const [content, branches, commitSHA] = await Promise.all([
const [content, branches, tags, commitSHA] = await Promise.all([
git.show(repo.name, params.ref, filePath),
git.branches(repo.name),
git.tags(repo.name),
git.resolveRef(repo.name, params.ref),
]);
if (!content || !commitSHA)
@@ -492,6 +507,7 @@ export const repoRoutes = new Elysia()
filePath={filePath}
view={view}
branches={branches}
tags={tags}
markdownHtml={markdownHtml}
/>,
);
@@ -539,7 +555,7 @@ export const repoRoutes = new Elysia()
});
})
.get("/:repo/edit/:ref/*", async ({ params, cookie }) => {
.get("/:repo/edit/:ref/*", async ({ params, query, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAdmin(user);
if (deny) return deny;
@@ -557,6 +573,8 @@ export const repoRoutes = new Elysia()
if (hasBinaryContent(content.subarray(0, 8000)))
return new Response("Not found", { status: 404 });
const queryError =
typeof query.error === "string" ? query.error : undefined;
return html(
<FileEdit
user={user!}
@@ -564,6 +582,7 @@ export const repoRoutes = new Elysia()
ref={params.ref}
filePath={filePath}
content={content.toString("utf-8")}
queryError={queryError}
/>,
);
})
@@ -582,8 +601,27 @@ export const repoRoutes = new Elysia()
if (!branches.includes(params.ref))
return new Response("Not found", { status: 404 });
const message =
body.message?.trim() || `Edited ${path.basename(filePath)}`;
const newPath = body.new_path?.trim() || undefined;
const targetPath =
newPath && newPath !== filePath ? newPath : filePath;
if (
newPath &&
newPath !== filePath &&
(newPath.startsWith("/") ||
newPath.includes("..") ||
newPath.includes("\0"))
) {
return redirect(
`/${repo.name}/edit/${params.ref}/${filePath}?error=${encodeURIComponent("Invalid file path.")}`,
);
}
const defaultMessage =
targetPath !== filePath
? `Rename ${path.basename(filePath)} to ${path.basename(targetPath)}`
: `Edited ${path.basename(filePath)}`;
const message = body.message?.trim() || defaultMessage;
const content = (body.content ?? "").replaceAll("\r\n", "\n");
const commit = await git.editFile(
@@ -594,6 +632,7 @@ export const repoRoutes = new Elysia()
message,
config.COMMITTER_NAME,
config.COMMITTER_EMAIL,
newPath,
);
return new Response(null, {
@@ -607,6 +646,7 @@ export const repoRoutes = new Elysia()
body: t.Object({
content: t.Optional(t.String()),
message: t.Optional(t.String()),
new_path: t.Optional(t.String()),
}),
},
)
@@ -625,13 +665,14 @@ export const repoRoutes = new Elysia()
const after = query.after?.trim() || null;
const prev = query.prev?.trim() || null;
const [rawCommits, branches] = await Promise.all([
const [rawCommits, branches, tags] = await Promise.all([
// When `after` is set: start at that SHA and skip it (--skip=1 is O(1)),
// then fetch LIMIT+1 to detect whether another page exists.
after
? git.log(repo.name, after, COMMITS_PER_PAGE + 1, 1)
: git.log(repo.name, params.ref, COMMITS_PER_PAGE + 1, 0),
git.branches(repo.name),
git.tags(repo.name),
]);
const hasNext = rawCommits.length > COMMITS_PER_PAGE;
@@ -658,6 +699,7 @@ export const repoRoutes = new Elysia()
ref={params.ref}
commits={commits}
branches={branches}
tags={tags}
olderUrl={olderUrl}
newerUrl={newerUrl}
/>,
@@ -880,4 +922,452 @@ export const repoRoutes = new Elysia()
{
body: t.Object({ id: t.Numeric() }),
},
)
// ── Branches ──────────────────────────────────────────────────────────────
.get("/:repo/branches", async ({ params, query, cookie }) => {
const user = await resolveSession(cookie.session.value);
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const allBranches = await git.branchesWithInfo(repo.name);
const page = Math.max(1, parseInt(String(query.page ?? "1"), 10) || 1);
const totalPages = Math.max(
1,
Math.ceil(allBranches.length / BRANCHES_PER_PAGE),
);
const safePage = Math.min(page, totalPages);
const branches = allBranches.slice(
(safePage - 1) * BRANCHES_PER_PAGE,
safePage * BRANCHES_PER_PAGE,
);
const success =
typeof query.success === "string" ? query.success : undefined;
const error = typeof query.error === "string" ? query.error : undefined;
return html(
<BranchList
user={user}
repo={repo}
branches={branches}
page={safePage}
totalPages={totalPages}
success={success}
error={error}
/>,
);
})
.post(
"/:repo/branches/create",
async ({ params, body, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAdmin(user);
if (deny) return deny;
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
const name = body.name?.trim() ?? "";
const sourceRef = body.source_ref?.trim() ?? "";
if (
!name ||
!/^[a-zA-Z0-9._][a-zA-Z0-9._\-/]*$/.test(name) ||
name.includes("..") ||
name.length > 255
) {
return redirect(
`/${repo.name}/branches?error=${encodeURIComponent("Invalid branch name.")}`,
);
}
if (!sourceRef) {
return redirect(
`/${repo.name}/branches?error=${encodeURIComponent("Source ref is required.")}`,
);
}
const result = await git.createBranch(repo.name, name, sourceRef);
if (result === "ok") {
return redirect(
`/${repo.name}/branches?success=${encodeURIComponent(`Branch "${name}" created.`)}`,
);
}
if (result === "already_exists") {
return redirect(
`/${repo.name}/branches?error=${encodeURIComponent(`Branch "${name}" already exists.`)}`,
);
}
if (result === "bad_ref") {
return redirect(
`/${repo.name}/branches?error=${encodeURIComponent(`"${sourceRef}" is not a valid ref.`)}`,
);
}
return redirect(
`/${repo.name}/branches?error=${encodeURIComponent("Failed to create branch.")}`,
);
},
{
body: t.Object({
name: t.String(),
source_ref: t.String(),
}),
},
)
.post(
"/:repo/branches/delete",
async ({ params, body, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAdmin(user);
if (deny) return deny;
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
const name = body.name?.trim() ?? "";
if (!name) {
return redirect(
`/${repo.name}/branches?error=${encodeURIComponent("Branch name is required.")}`,
);
}
if (name === repo.default_branch) {
return redirect(
`/${repo.name}/branches?error=${encodeURIComponent("Cannot delete the default branch.")}`,
);
}
const result = await git.deleteBranch(repo.name, name);
if (result === "ok") {
return redirect(
`/${repo.name}/branches?success=${encodeURIComponent(`Branch "${name}" deleted.`)}`,
);
}
if (result === "not_found") {
return redirect(
`/${repo.name}/branches?error=${encodeURIComponent(`Branch "${name}" not found.`)}`,
);
}
return redirect(
`/${repo.name}/branches?error=${encodeURIComponent("Failed to delete branch.")}`,
);
},
{
body: t.Object({ name: t.String() }),
},
)
.post(
"/:repo/branches/rename",
async ({ params, body, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAdmin(user);
if (deny) return deny;
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
const oldName = body.old_name?.trim() ?? "";
const newName = body.new_name?.trim() ?? "";
if (
!newName ||
!/^[a-zA-Z0-9._][a-zA-Z0-9._\-/]*$/.test(newName) ||
newName.includes("..") ||
newName.length > 255
) {
return redirect(
`/${repo.name}/branches?error=${encodeURIComponent("Invalid branch name.")}`,
);
}
const result = await git.renameBranch(repo.name, oldName, newName);
if (result === "ok") {
// Keep default_branch in DB in sync if we renamed it
if (oldName === repo.default_branch) {
await db
.updateTable("repositories")
.set({ default_branch: newName })
.where("id", "=", repo.id)
.execute();
await git.setHead(repo.name, newName).catch(() => {});
}
return redirect(
`/${repo.name}/branches?success=${encodeURIComponent(`Branch renamed to "${newName}".`)}`,
);
}
if (result === "not_found") {
return redirect(
`/${repo.name}/branches?error=${encodeURIComponent(`Branch "${oldName}" not found.`)}`,
);
}
if (result === "already_exists") {
return redirect(
`/${repo.name}/branches?error=${encodeURIComponent(`Branch "${newName}" already exists.`)}`,
);
}
return redirect(
`/${repo.name}/branches?error=${encodeURIComponent("Failed to rename branch.")}`,
);
},
{
body: t.Object({
old_name: t.String(),
new_name: t.String(),
}),
},
)
// ── Tags ──────────────────────────────────────────────────────────────────
.get("/:repo/tags", async ({ params, query, cookie }) => {
const user = await resolveSession(cookie.session.value);
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const [allTags, releases] = await Promise.all([
git.tagsWithInfo(repo.name),
db
.selectFrom("releases")
.select(["id", "tag_name"])
.where("repo_id", "=", repo.id)
.where("tag_name", "is not", null)
.execute(),
]);
const tagReleaseMap = new Map<string, number>();
for (const r of releases) {
if (r.tag_name) tagReleaseMap.set(r.tag_name, r.id);
}
const page = Math.max(1, parseInt(String(query.page ?? "1"), 10) || 1);
const totalPages = Math.max(
1,
Math.ceil(allTags.length / TAGS_PER_PAGE),
);
const safePage = Math.min(page, totalPages);
const tags = allTags.slice(
(safePage - 1) * TAGS_PER_PAGE,
safePage * TAGS_PER_PAGE,
);
const success =
typeof query.success === "string" ? query.success : undefined;
const error = typeof query.error === "string" ? query.error : undefined;
return html(
<TagList
user={user}
repo={repo}
tags={tags}
tagReleaseMap={tagReleaseMap}
page={safePage}
totalPages={totalPages}
success={success}
error={error}
/>,
);
})
.post(
"/:repo/tags/create",
async ({ params, body, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAdmin(user);
if (deny) return deny;
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
const tagName = body.name?.trim() ?? "";
const ref = body.ref?.trim() ?? "";
const message = body.message?.trim() || undefined;
if (!tagName || !/^[a-zA-Z0-9._\-+]+$/.test(tagName)) {
return redirect(
`/${repo.name}/tags?error=${encodeURIComponent("Invalid tag name.")}`,
);
}
if (!ref) {
return redirect(
`/${repo.name}/tags?error=${encodeURIComponent("Target ref is required.")}`,
);
}
const result = await git.createTag(
repo.name,
tagName,
ref,
message,
message ? config.COMMITTER_NAME : undefined,
message ? config.COMMITTER_EMAIL : undefined,
);
if (result === "ok") {
return redirect(
`/${repo.name}/tags?success=${encodeURIComponent(`Tag "${tagName}" created.`)}`,
);
}
if (result === "already_exists") {
return redirect(
`/${repo.name}/tags?error=${encodeURIComponent(`Tag "${tagName}" already exists.`)}`,
);
}
if (result === "bad_ref") {
return redirect(
`/${repo.name}/tags?error=${encodeURIComponent(`"${ref}" is not a valid ref.`)}`,
);
}
return redirect(
`/${repo.name}/tags?error=${encodeURIComponent("Failed to create tag.")}`,
);
},
{
body: t.Object({
name: t.String(),
ref: t.String(),
message: t.Optional(t.String()),
}),
},
)
.post(
"/:repo/tags/delete",
async ({ params, body, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAdmin(user);
if (deny) return deny;
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
const tagName = body.name?.trim() ?? "";
if (!tagName) {
return redirect(
`/${repo.name}/tags?error=${encodeURIComponent("Tag name is required.")}`,
);
}
const result = await git.deleteTag(repo.name, tagName);
if (result === "ok") {
return redirect(
`/${repo.name}/tags?success=${encodeURIComponent(`Tag "${tagName}" deleted.`)}`,
);
}
if (result === "not_found") {
return redirect(
`/${repo.name}/tags?error=${encodeURIComponent(`Tag "${tagName}" not found.`)}`,
);
}
return redirect(
`/${repo.name}/tags?error=${encodeURIComponent("Failed to delete tag.")}`,
);
},
{
body: t.Object({ name: t.String() }),
},
)
// ── File creation ─────────────────────────────────────────────────────────
.get("/:repo/new-file/:ref", async ({ params, query, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAdmin(user);
if (deny) return deny;
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
const dir = typeof query.dir === "string" ? query.dir : "";
const error = typeof query.error === "string" ? query.error : undefined;
return html(
<NewFileForm
user={user!}
repo={repo}
ref={params.ref}
dir={dir}
error={error}
/>,
);
})
.post(
"/:repo/new-file/:ref",
async ({ params, body, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAdmin(user);
if (deny) return deny;
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
const filePath = body.path?.trim() ?? "";
const content = body.content ?? "";
const message = body.message?.trim() || `Add ${filePath}`;
if (
!filePath ||
filePath.startsWith("/") ||
filePath.includes("..") ||
filePath.includes("\0")
) {
return redirect(
`/${repo.name}/new-file/${params.ref}?error=${encodeURIComponent("Invalid file path.")}`,
);
}
// Ensure we're on a branch
const branches = await git.branches(repo.name);
if (branches.length > 0 && !branches.includes(params.ref)) {
return redirect(
`/${repo.name}/new-file/${params.ref}?error=${encodeURIComponent("Can only create files on a branch.")}`,
);
}
try {
const commit = await git.createFile(
repo.name,
params.ref,
filePath,
content,
message,
config.COMMITTER_NAME,
config.COMMITTER_EMAIL,
);
return redirect(`/${repo.name}/commit/${commit}`);
} catch {
return redirect(
`/${repo.name}/new-file/${params.ref}?error=${encodeURIComponent("Failed to create file.")}`,
);
}
},
{
body: t.Object({
path: t.String(),
content: t.Optional(t.String()),
message: t.Optional(t.String()),
}),
},
)
// ── File deletion ─────────────────────────────────────────────────────────
.post(
"/:repo/delete-file/:ref/*",
async ({ params, body, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAdmin(user);
if (deny) return deny;
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
const filePath = decodeURIComponent(params["*"]);
const message = body.message?.trim() || `Delete ${filePath}`;
try {
const commit = await git.deleteFile(
repo.name,
params.ref,
filePath,
message,
config.COMMITTER_NAME,
config.COMMITTER_EMAIL,
);
return redirect(`/${repo.name}/commit/${commit}`);
} catch {
return redirect(
`/${repo.name}/blob/${params.ref}/${filePath}?error=${encodeURIComponent("Failed to delete file.")}`,
);
}
},
{
body: t.Object({
message: t.Optional(t.String()),
}),
},
);
Msrc/services/git.ts
@@ -193,6 +193,23 @@ export function extractPatchSubject(patch: string): string {
return "";
}
export interface BranchInfo {
name: string;
shortHash: string;
subject: string;
authorName: string;
date: string;
}
export interface TagInfo {
name: string;
shortHash: string;
subject: string;
taggerName: string;
date: string;
isAnnotated: boolean;
}
export interface PatchMeta {
subject: string;
body: string;
@@ -330,7 +347,7 @@ export const git = {
async diff(name: string, sha: string): Promise<string> {
const p = repoPath(name);
try {
return await $`git -C ${p} diff-tree --no-commit-id -r -p --root ${sha}`.text();
return await $`git -C ${p} diff-tree --no-commit-id -r -p -M --root ${sha}`.text();
} catch {
return "";
}
@@ -359,6 +376,77 @@ export const git = {
}
},
async tags(name: string): Promise<string[]> {
const p = repoPath(name);
try {
const fmt = "%(refname:short)";
const out =
await $`git -C ${p} for-each-ref --format=${fmt} refs/tags/`.text();
return out.split("\n").filter(Boolean);
} catch {
return [];
}
},
async branchesWithInfo(name: string): Promise<BranchInfo[]> {
const p = repoPath(name);
try {
// Use actual unit separator byte (\x1f) — git for-each-ref does not
// support the %x1f hex escape (that is a git-log pretty-format feature).
const sep = "\x1f";
const fmt = `%(refname:short)${sep}%(objectname:short)${sep}%(contents:subject)${sep}%(authorname)${sep}%(authordate:iso8601)`;
const out =
await $`git -C ${p} for-each-ref --format=${fmt} refs/heads/`.text();
return out
.split("\n")
.filter(Boolean)
.map((line) => {
const parts = line.split(sep);
return {
name: parts[0] ?? "",
shortHash: parts[1] ?? "",
subject: parts[2] ?? "",
authorName: parts[3] ?? "",
date: parts[4] ?? "",
};
});
} catch {
return [];
}
},
async tagsWithInfo(name: string): Promise<TagInfo[]> {
const p = repoPath(name);
try {
// Use actual unit separator byte (\x1f) — git for-each-ref does not
// support the %x1f hex escape (that is a git-log pretty-format feature).
// %(*objectname:short) is the dereferenced commit for annotated tags; empty for lightweight.
const sep = "\x1f";
const fmt = `%(refname:short)${sep}%(*objectname:short)${sep}%(objectname:short)${sep}%(contents:subject)${sep}%(taggername)${sep}%(creatordate:iso8601)`;
const out =
await $`git -C ${p} for-each-ref --format=${fmt} refs/tags/`.text();
return out
.split("\n")
.filter(Boolean)
.map((line) => {
const parts = line.split(sep);
const derefHash = (parts[1] ?? "").trim();
const ownHash = (parts[2] ?? "").trim();
const isAnnotated = derefHash.length > 0;
return {
name: parts[0] ?? "",
shortHash: isAnnotated ? derefHash : ownHash,
subject: parts[3] ?? "",
taggerName: parts[4] ?? "",
date: parts[5] ?? "",
isAnnotated,
};
});
} catch {
return [];
}
},
async defaultBranch(name: string): Promise<string> {
const p = repoPath(name);
try {
@@ -492,18 +580,188 @@ export const git = {
message: string,
committerName: string,
committerEmail: string,
newPath?: string,
): Promise<string> {
return withRepoLock(name, async () => {
const targetPath =
newPath && newPath !== filePath ? newPath : filePath;
const isMove = targetPath !== filePath;
const p = repoPath(name);
const tmpFile = `/tmp/hf-edit-${Date.now()}-${Math.random().toString(36).slice(2)}`;
try {
await Bun.write(tmpFile, content);
await $`git -C ${p} read-tree refs/heads/${branch}`;
if (isMove) {
await $`git --work-tree=/tmp -C ${p} read-tree refs/heads/${branch}`;
} else {
await $`git -C ${p} read-tree refs/heads/${branch}`;
}
const blobHash = (
await $`git -C ${p} hash-object -w ${tmpFile}`.text()
).trim();
if (isMove) {
await $`git --work-tree=/tmp -C ${p} update-index --remove ${filePath}`;
}
await $`git -C ${p} update-index --add --cacheinfo 100644,${blobHash},${targetPath}`;
const tree = isMove
? (
await $`git --work-tree=/tmp -C ${p} write-tree`.text()
).trim()
: (await $`git -C ${p} write-tree`.text()).trim();
const parent = (
await $`git -C ${p} rev-parse refs/heads/${branch}`.text()
).trim();
const sigArgs = [
"-c",
"gpg.format=ssh",
"-c",
`user.signingKey=${paths.SSH_HOST_KEY_PATH}`,
];
const commit = (
await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parent} -m ${message}`
.env({
...gitEnv,
GIT_AUTHOR_NAME: committerName,
GIT_AUTHOR_EMAIL: committerEmail,
GIT_COMMITTER_NAME: committerName,
GIT_COMMITTER_EMAIL: committerEmail,
})
.text()
).trim();
await $`git -C ${p} update-ref refs/heads/${branch} ${commit}`;
return commit;
} finally {
await $`rm -f ${tmpFile}`.quiet().nothrow();
}
});
},
async createFile(
name: string,
branch: string,
filePath: string,
content: string,
message: string,
committerName: string,
committerEmail: string,
): Promise<string> {
return withRepoLock(name, async () => {
const p = repoPath(name);
const tmpFile = `/tmp/hf-new-${Date.now()}-${Math.random().toString(36).slice(2)}`;
try {
await Bun.write(tmpFile, content);
const parentSha = await git.resolveRef(
name,
`refs/heads/${branch}`,
);
if (parentSha) {
await $`git -C ${p} read-tree refs/heads/${branch}`;
}
const blobHash = (
await $`git -C ${p} hash-object -w ${tmpFile}`.text()
).trim();
await $`git -C ${p} update-index --add --cacheinfo 100644,${blobHash},${filePath}`;
const tree = (await $`git -C ${p} write-tree`.text()).trim();
const sigArgs = [
"-c",
"gpg.format=ssh",
"-c",
`user.signingKey=${paths.SSH_HOST_KEY_PATH}`,
];
const commitEnv = {
...gitEnv,
GIT_AUTHOR_NAME: committerName,
GIT_AUTHOR_EMAIL: committerEmail,
GIT_COMMITTER_NAME: committerName,
GIT_COMMITTER_EMAIL: committerEmail,
};
const commit = parentSha
? (
await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parentSha} -m ${message}`
.env(commitEnv)
.text()
).trim()
: (
await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -m ${message}`
.env(commitEnv)
.text()
).trim();
await $`git -C ${p} update-ref refs/heads/${branch} ${commit}`;
return commit;
} finally {
await $`rm -f ${tmpFile}`.quiet().nothrow();
}
});
},
async deleteFile(
name: string,
branch: string,
filePath: string,
message: string,
committerName: string,
committerEmail: string,
): Promise<string> {
return withRepoLock(name, async () => {
const p = repoPath(name);
// --work-tree=/tmp is needed because bare repos have no work tree and
// `update-index --remove` requires one (even though it only touches the index).
await $`git --work-tree=/tmp -C ${p} read-tree refs/heads/${branch}`;
await $`git --work-tree=/tmp -C ${p} update-index --remove ${filePath}`;
const tree = (
await $`git --work-tree=/tmp -C ${p} write-tree`.text()
).trim();
const parent = (
await $`git -C ${p} rev-parse refs/heads/${branch}`.text()
).trim();
const sigArgs = [
"-c",
"gpg.format=ssh",
"-c",
`user.signingKey=${paths.SSH_HOST_KEY_PATH}`,
];
const commit = (
await $`git ${sigArgs} -C ${p} commit-tree -S ${tree} -p ${parent} -m ${message}`
.env({
...gitEnv,
GIT_AUTHOR_NAME: committerName,
GIT_AUTHOR_EMAIL: committerEmail,
GIT_COMMITTER_NAME: committerName,
GIT_COMMITTER_EMAIL: committerEmail,
})
.text()
).trim();
await $`git -C ${p} update-ref refs/heads/${branch} ${commit}`;
return commit;
});
},
async moveFile(
name: string,
branch: string,
oldPath: string,
newPath: string,
message: string,
committerName: string,
committerEmail: string,
): Promise<string> {
return withRepoLock(name, async () => {
const p = repoPath(name);
const tmpFile = `/tmp/hf-move-${Date.now()}-${Math.random().toString(36).slice(2)}`;
try {
const contentBuf =
await $`git -C ${p} show ${`${branch}:${oldPath}`}`.arrayBuffer();
await Bun.write(tmpFile, contentBuf);
// --work-tree=/tmp is needed because bare repos have no work tree and
// `update-index --remove` requires one (even though it only touches the index).
await $`git --work-tree=/tmp -C ${p} read-tree refs/heads/${branch}`;
const blobHash = (
await $`git -C ${p} hash-object -w ${tmpFile}`.text()
).trim();
await $`git --work-tree=/tmp -C ${p} update-index --remove ${oldPath}`;
await $`git -C ${p} update-index --add --cacheinfo 100644,${blobHash},${newPath}`;
const tree = (
await $`git --work-tree=/tmp -C ${p} write-tree`.text()
).trim();
const parent = (
await $`git -C ${p} rev-parse refs/heads/${branch}`.text()
).trim();
@@ -571,6 +829,93 @@ export const git = {
});
},
async createBranch(
name: string,
branchName: string,
sourceRef: string,
): Promise<"ok" | "already_exists" | "bad_ref" | "error"> {
return withRepoLock(name, async () => {
const p = repoPath(name);
try {
const sha = await git.resolveRef(name, sourceRef);
if (!sha) return "bad_ref";
const exists = await git.resolveRef(
name,
`refs/heads/${branchName}`,
);
if (exists) return "already_exists";
await $`git -C ${p} update-ref refs/heads/${branchName} ${sha}`;
return "ok";
} catch {
return "error";
}
});
},
async deleteBranch(
name: string,
branchName: string,
): Promise<"ok" | "not_found" | "error"> {
return withRepoLock(name, async () => {
const p = repoPath(name);
try {
const exists = await git.resolveRef(
name,
`refs/heads/${branchName}`,
);
if (!exists) return "not_found";
await $`git -C ${p} update-ref -d refs/heads/${branchName}`;
return "ok";
} catch {
return "error";
}
});
},
async renameBranch(
name: string,
oldName: string,
newName: string,
): Promise<"ok" | "not_found" | "already_exists" | "error"> {
return withRepoLock(name, async () => {
const p = repoPath(name);
try {
const sha = await git.resolveRef(name, `refs/heads/${oldName}`);
if (!sha) return "not_found";
const exists = await git.resolveRef(
name,
`refs/heads/${newName}`,
);
if (exists) return "already_exists";
await $`git -C ${p} update-ref refs/heads/${newName} ${sha}`;
await $`git -C ${p} update-ref -d refs/heads/${oldName}`;
return "ok";
} catch {
return "error";
}
});
},
async deleteTag(
name: string,
tagName: string,
): Promise<"ok" | "not_found" | "error"> {
return withRepoLock(name, async () => {
const p = repoPath(name);
try {
const exists = await git.resolveRef(
name,
`refs/tags/${tagName}`,
);
if (!exists) return "not_found";
await $`git -C ${p} tag -d ${tagName}`;
return "ok";
} catch {
return "error";
}
});
},
async setHead(name: string, branch: string): Promise<void> {
const p = repoPath(name);
await $`git -C ${p} symbolic-ref HEAD refs/heads/${branch}`;
Msrc/styles/code.css
@@ -72,6 +72,8 @@
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
@@ -81,12 +83,19 @@
.file-blob-name {
font-size: var(--text-sm);
font-weight: 500;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-blob-actions {
display: flex;
align-items: center;
gap: var(--space-2);
}
.file-blob-actions-wrap {
flex-wrap: wrap;
}
.file-blob-body {
border: 1px solid var(--color-border);
border-top: none;
Msrc/styles/components.css
@@ -376,6 +376,14 @@
border-color: var(--color-accent);
background: var(--color-accent-bg);
}
.badge-release {
color: var(--color-success);
border-color: var(--color-success);
background: var(--color-success-bg);
}
.badge-release:hover {
text-decoration: underline;
}
.issue-badge,
.patch-badge {
display: inline-flex;
@@ -444,6 +452,8 @@
.breadcrumb a {
color: var(--color-link);
text-decoration: none;
text-overflow: ellipsis;
overflow: hidden;
}
.breadcrumb a:hover {
text-decoration: underline;
@@ -453,6 +463,8 @@
}
.breadcrumb-current {
color: var(--color-text-muted);
text-overflow: ellipsis;
overflow: hidden;
}
/* --- Issue / Patch list --- */
@@ -1796,4 +1808,121 @@
display: flex;
gap: var(--space-2);
}
/* --- list-heading (inside list-header) --- */
.list-heading {
font-size: var(--text-lg);
font-weight: 600;
margin: 0;
}
/* --- Branch / Tag ref list --- */
.ref-list {
border: 1px solid var(--color-border);
border-top: none;
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
}
.ref-item {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-muted);
}
.ref-item:last-child {
border-bottom: none;
}
.ref-name-row {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-1);
}
.ref-name {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text);
text-decoration: none;
}
.ref-name:hover {
color: var(--color-link);
}
.ref-meta-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2) var(--space-3);
font-size: var(--text-xs);
color: var(--color-text-muted);
min-width: 0;
}
.ref-author {
white-space: nowrap;
}
.ref-hash {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--color-link);
text-decoration: none;
background: var(--color-bg-inset);
padding: 1px var(--space-2);
border-radius: var(--radius-sm);
white-space: nowrap;
flex-shrink: 0;
}
.ref-hash:hover {
text-decoration: underline;
}
.ref-subject {
flex: 1;
min-width: 60px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ref-date {
white-space: nowrap;
flex-shrink: 0;
}
.ref-actions {
display: flex;
align-items: center;
gap: var(--space-2);
margin-left: auto;
flex-shrink: 0;
}
/* --- Inline confirm-details (for row-level actions) --- */
.confirm-details-inline {
position: relative;
display: inline-block;
}
/* --- Popup form (for new branch / new tag popups) --- */
.confirm-popup-form {
min-width: 260px;
}
.popup-form {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.popup-form .form-group {
margin-bottom: 0;
}
.popup-form input[type="text"] {
width: 100%;
box-sizing: border-box;
}
/* --- Confirm warning (tag linked to release) --- */
.confirm-warning {
font-size: var(--text-xs);
color: var(--color-warning);
margin: 0 0 var(--space-2);
}
/* --- Tree toolbar actions group --- */
.tree-toolbar-actions {
display: flex;
align-items: center;
gap: var(--space-2);
}
}
Msrc/views/issues/IssueDetail.tsx
@@ -79,7 +79,9 @@ export function IssueDetail({
name="title"
value={issue.title}
required
maxlength={config.MAX_TITLE_BYTES}
maxlength={
config.MAX_TITLE_BYTES
}
/>
<input
type="hidden"
@@ -292,7 +294,9 @@ export function IssueDetail({
id="edit-issue-body"
name="edit_body"
rows="6"
maxlength={config.MAX_TEXT_BODY_BYTES}
maxlength={
config.MAX_TEXT_BODY_BYTES
}
>
{issue.body}
</textarea>
@@ -364,7 +368,9 @@ export function IssueDetail({
class="form-input"
name="edit_body"
rows="6"
maxlength={config.MAX_TEXT_BODY_BYTES}
maxlength={
config.MAX_TEXT_BODY_BYTES
}
>
{comment.body}
</textarea>
Msrc/views/patches/NewPatch.tsx
@@ -52,7 +52,12 @@ export function NewPatch({
(Markdown supported, optional)
</span>
</label>
<textarea id="description" name="description" rows="5" maxlength={config.MAX_TEXT_BODY_BYTES}>
<textarea
id="description"
name="description"
rows="5"
maxlength={config.MAX_TEXT_BODY_BYTES}
>
{template ?? ""}
</textarea>
</div>
Msrc/views/patches/PatchDetail.tsx
@@ -100,7 +100,9 @@ export function PatchDetail({
name="title"
value={patch.title}
required
maxlength={config.MAX_TITLE_BYTES}
maxlength={
config.MAX_TITLE_BYTES
}
/>
<input
type="hidden"
@@ -374,7 +376,9 @@ export function PatchDetail({
id="edit-patch-desc"
name="edit_description"
rows="6"
maxlength={config.MAX_TEXT_BODY_BYTES}
maxlength={
config.MAX_TEXT_BODY_BYTES
}
>
{patch.description}
</textarea>
@@ -474,7 +478,9 @@ export function PatchDetail({
class="form-input"
name="edit_body"
rows="6"
maxlength={config.MAX_TEXT_BODY_BYTES}
maxlength={
config.MAX_TEXT_BODY_BYTES
}
>
{comment.body}
</textarea>
@@ -515,7 +521,9 @@ export function PatchDetail({
<textarea
name="body"
rows="6"
maxlength={config.MAX_TEXT_BODY_BYTES}
maxlength={
config.MAX_TEXT_BODY_BYTES
}
placeholder="Leave a comment (Markdown supported)"
required
/>
Asrc/views/repos/BranchList.tsx
@@ -0,0 +1,213 @@
import type { RepositoryRow } from "../../db/index.ts";
import { formatDateTime } from "../../lib/formatDate.ts";
import type { SessionUser } from "../../middleware/session.ts";
import type { BranchInfo } from "../../services/git.ts";
import { Layout } from "../layout.tsx";
import { Pagination } from "../Pagination.tsx";
import { RepoHeader } from "./RepoHeader.tsx";
import { RepoNav } from "./RepoNav.tsx";
interface BranchListProps {
user: SessionUser | null;
repo: RepositoryRow;
branches: BranchInfo[];
page: number;
totalPages: number;
success?: string;
error?: string;
}
export function BranchList({
user,
repo,
branches,
page,
totalPages,
success,
error,
}: BranchListProps) {
return (
<Layout user={user} title={`Branches — ${repo.name}`}>
<div class="container">
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="branches" user={user} />
{success && <p class="form-success">{success}</p>}
{error && <p class="form-error">{error}</p>}
<div class="list-header">
<h2 class="list-heading">Branches</h2>
{user?.isAdmin && (
<details class="confirm-details">
<summary class="btn btn-primary btn-sm">
New branch
</summary>
<div class="confirm-popup confirm-popup-form">
<form
method="POST"
action={`/${repo.name}/branches/create`}
class="popup-form"
>
<div class="form-group">
<label for="new-branch-name">
Branch name
</label>
<input
id="new-branch-name"
name="name"
type="text"
required
placeholder="feature/my-branch"
maxlength="255"
/>
</div>
<div class="form-group">
<label for="new-branch-source">
From
</label>
<input
id="new-branch-source"
name="source_ref"
type="text"
required
value={repo.default_branch}
placeholder="branch, tag, or commit"
maxlength="255"
/>
</div>
<button
type="submit"
class="btn btn-primary btn-sm"
>
Create branch
</button>
</form>
</div>
</details>
)}
</div>
{branches.length === 0 ? (
<div class="empty-state">
<p>No branches yet.</p>
</div>
) : (
<ul class="ref-list">
{branches.map((b) => (
<li class="ref-item">
<div class="ref-name-row">
<a
href={`/${repo.name}/tree/${b.name}`}
class="ref-name"
>
{b.name}
</a>
{b.name === repo.default_branch && (
<span class="badge">default</span>
)}
</div>
<div class="ref-meta-row">
{b.authorName && (
<span class="ref-author">
{b.authorName}
</span>
)}
{b.shortHash && (
<a
href={`/${repo.name}/commit/${b.shortHash}`}
class="ref-hash mono"
>
{b.shortHash}
</a>
)}
{b.subject && (
<span class="ref-subject">
{b.subject}
</span>
)}
{b.date && (
<time
class="ref-date"
datetime={b.date}
>
{formatDateTime(b.date)}
</time>
)}
{user?.isAdmin && (
<span class="ref-actions">
<details class="confirm-details confirm-details-inline">
<summary class="btn btn-sm">
Rename
</summary>
<div class="confirm-popup">
<form
method="POST"
action={`/${repo.name}/branches/rename`}
class="popup-form"
>
<input
type="hidden"
name="old_name"
value={b.name}
/>
<input
name="new_name"
type="text"
required
placeholder="new-name"
value={b.name}
maxlength="255"
/>
<button
type="submit"
class="btn btn-sm btn-primary"
>
Rename
</button>
</form>
</div>
</details>
{b.name !== repo.default_branch && (
<details class="confirm-details confirm-details-inline">
<summary class="btn btn-sm btn-danger">
Delete
</summary>
<div class="confirm-popup">
Delete branch{" "}
<strong>
{b.name}
</strong>
?
<form
method="POST"
action={`/${repo.name}/branches/delete`}
class="inline-form"
>
<input
type="hidden"
name="name"
value={b.name}
/>
<button
type="submit"
class="btn btn-sm btn-danger"
>
Yes, delete
</button>
</form>
</div>
</details>
)}
</span>
)}
</div>
</li>
))}
</ul>
)}
<Pagination
page={page}
totalPages={totalPages}
pageUrlTemplate={`/${repo.name}/branches?page={page}`}
/>
</div>
</Layout>
);
}
Msrc/views/repos/BranchSelector.tsx
@@ -1,6 +1,7 @@
interface BranchSelectorProps {
repoName: string;
branches: string[];
tags?: string[];
currentRef: string;
view: "tree" | "commits" | "blob";
/** subpath for tree, full file path for blob */
@@ -10,12 +11,14 @@ interface BranchSelectorProps {
export function BranchSelector({
repoName,
branches,
tags = [],
currentRef,
view,
path,
}: BranchSelectorProps) {
if (branches.length === 0) return "";
const isDetached = !branches.includes(currentRef);
if (branches.length === 0 && tags.length === 0) return "";
const isDetached =
!branches.includes(currentRef) && !tags.includes(currentRef);
const shortRef =
isDetached && currentRef.length > 8
? currentRef.slice(0, 8)
@@ -39,14 +42,28 @@ export function BranchSelector({
{shortRef} (detached)
</option>
)}
{branches.map((b) => (
<option
value={b}
selected={b === currentRef ? true : undefined}
>
{b}
</option>
))}
(
<optgroup label="Branches">
{branches.map((b) => (
<option
value={b}
selected={b === currentRef ? true : undefined}
>
{b}
</option>
))}
</optgroup>
<optgroup label="Tags">
{tags.map((t) => (
<option
value={t}
selected={t === currentRef ? true : undefined}
>
{t}
</option>
))}
</optgroup>
)
</select>
<noscript>
<button type="submit" class="btn btn-sm">
Msrc/views/repos/CommitLog.tsx
@@ -14,6 +14,7 @@ interface CommitLogProps {
ref: string;
commits: CommitEntry[];
branches: string[];
tags?: string[];
/** URL for the next (older) page, or null if this is the last page. */
olderUrl: string | null;
/** URL for the previous (newer) page, or null if this is the first page. */
@@ -26,6 +27,7 @@ export function CommitLog({
ref: logRef,
commits,
branches,
tags,
olderUrl,
newerUrl,
}: CommitLogProps) {
@@ -39,6 +41,7 @@ export function CommitLog({
<BranchSelector
repoName={repo.name}
branches={branches}
tags={tags}
currentRef={logRef}
view="commits"
/>
Msrc/views/repos/FileBlob.tsx
@@ -13,6 +13,7 @@ interface FileBlobProps {
filePath: string;
view: FileView;
branches: string[];
tags?: string[];
markdownHtml?: string;
}
@@ -23,6 +24,7 @@ export function FileBlob({
filePath,
view,
branches,
tags,
markdownHtml,
}: FileBlobProps) {
const parts = filePath.split("/");
@@ -61,10 +63,11 @@ export function FileBlob({
</div>
<div class="file-blob-header">
<span class="file-blob-name">{filename}</span>
<div class="file-blob-actions">
<div class="file-blob-actions file-blob-actions-wrap">
<BranchSelector
repoName={repo.name}
branches={branches}
tags={tags}
currentRef={blobRef}
view="blob"
path={filePath}
@@ -85,6 +88,37 @@ export function FileBlob({
Edit
</a>
)}
{branches.includes(blobRef) && user?.isAdmin && (
<details class="confirm-details">
<summary class="btn btn-sm btn-danger">
Delete
</summary>
<div class="confirm-popup">
Delete <strong>{filename}</strong>?
<form
method="POST"
action={`/${repo.name}/delete-file/${blobRef}/${filePath}`}
class="popup-form"
style="margin-top: var(--space-2);"
>
<div class="form-group">
<input
name="message"
type="text"
placeholder={`Delete ${filename}`}
maxlength="500"
/>
</div>
<button
type="submit"
class="btn btn-sm btn-danger"
>
Yes, delete
</button>
</form>
</div>
</details>
)}
</div>
</div>
<div class="file-blob-body">
Msrc/views/repos/FileEdit.tsx
@@ -11,6 +11,7 @@ interface FileEditProps {
filePath: string;
content: string;
error?: string;
queryError?: string;
}
export function FileEdit({
@@ -20,6 +21,7 @@ export function FileEdit({
filePath,
content,
error,
queryError,
}: FileEditProps) {
const parts = filePath.split("/");
const filename = parts[parts.length - 1] ?? filePath;
@@ -54,7 +56,9 @@ export function FileEdit({
<p class="form-hint">
WARNING: Line endings are normalized to LF (\n) on save.
</p>
{error && <p class="form-error">{error}</p>}
{(error || queryError) && (
<p class="form-error">{error ?? queryError}</p>
)}
<form
method="POST"
action={`/${repo.name}/edit/${editRef}/${filePath}`}
@@ -90,6 +94,17 @@ export function FileEdit({
>
Committing directly to <strong>{editRef}</strong>
</p>
<div class="form-group">
<label for="file-path">File path</label>
<input
id="file-path"
name="new_path"
type="text"
value={filePath}
class="mono"
maxlength="1000"
/>
</div>
<div class="form-group">
<label for="message">Commit message</label>
<textarea
Msrc/views/repos/FileTree.tsx
@@ -14,6 +14,7 @@ interface FileTreeProps {
subpath: string;
entries: TreeEntry[];
branches: string[];
tags?: string[];
readmeHtml?: string | null;
readmePath?: string;
}
@@ -25,6 +26,7 @@ export function FileTree({
subpath,
entries,
branches,
tags,
readmeHtml,
readmePath,
}: FileTreeProps) {
@@ -41,10 +43,19 @@ export function FileTree({
<BranchSelector
repoName={repo.name}
branches={branches}
tags={tags}
currentRef={treeRef}
view="tree"
path={subpath}
/>
{branches.includes(treeRef) && user?.isAdmin && (
<a
href={`/${repo.name}/new-file/${treeRef}${subpath ? `?dir=${encodeURIComponent(subpath)}` : ""}`}
class="btn btn-sm btn-primary"
>
New file
</a>
)}
</div>
{subpath && (
<div class="breadcrumb">
Asrc/views/repos/NewFileForm.tsx
@@ -0,0 +1,98 @@
import type { RepositoryRow } from "../../db/index.ts";
import type { SessionUser } from "../../middleware/session.ts";
import { Layout } from "../layout.tsx";
import { RepoHeader } from "../repos/RepoHeader.tsx";
import { RepoNav } from "./RepoNav.tsx";
interface NewFileFormProps {
user: SessionUser;
repo: RepositoryRow;
ref: string;
dir?: string;
error?: string;
}
export function NewFileForm({
user,
repo,
ref: treeRef,
dir,
error,
}: NewFileFormProps) {
const defaultPath = dir ? `${dir}/` : "";
const cancelHref = dir
? `/${repo.name}/tree/${treeRef}/${dir}`
: `/${repo.name}/tree/${treeRef}`;
return (
<Layout user={user} title={`New file — ${repo.name}`}>
<div class="container">
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="code" user={user} />
{error && <p class="form-error">{error}</p>}
<form
method="POST"
action={`/${repo.name}/new-file/${treeRef}`}
>
<div class="file-blob-header">
<span class="file-blob-name">New file</span>
<div class="file-blob-actions">
<a href={cancelHref} class="btn btn-sm">
Cancel
</a>
</div>
</div>
<div class="file-blob-body">
<textarea
name="content"
class="file-edit-textarea"
rows="20"
spellcheck="false"
autocorrect="off"
autocapitalize="off"
{...{ autocomplete: "off" }}
placeholder="File contents..."
/>
</div>
<div class="form-card">
<p
class="form-hint"
style="margin-bottom: var(--space-4);"
>
Creating file on <strong>{treeRef}</strong>
</p>
<div class="form-group">
<label for="file-path">File path</label>
<input
id="file-path"
name="path"
type="text"
required
value={defaultPath}
placeholder="path/to/file.txt"
class="mono"
maxlength="1000"
/>
</div>
<div class="form-group">
<label for="message">Commit message</label>
<textarea
id="message"
name="message"
rows="3"
placeholder="Add new file"
/>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
Create file
</button>
<a href={cancelHref} class="btn">
Cancel
</a>
</div>
</div>
</form>
</div>
</Layout>
);
}
Msrc/views/repos/RepoHome.tsx
@@ -16,6 +16,7 @@ interface RepoHomeProps {
readmePath?: string;
hasContent: boolean;
branches: string[];
tags?: string[];
}
export function RepoHome({
@@ -26,6 +27,7 @@ export function RepoHome({
readmePath,
hasContent,
branches,
tags,
}: RepoHomeProps) {
return (
<Layout user={user} title={repo.name}>
@@ -54,40 +56,52 @@ git push origin main`}</code>
<BranchSelector
repoName={repo.name}
branches={branches}
tags={tags}
currentRef={repo.default_branch}
view="tree"
/>
<details class="clone-popup-details">
<summary class="btn btn-sm btn-primary">
Clone
</summary>
<div class="clone-popup">
<div class="clone-url-row">
<span class="clone-url-label">
HTTP
</span>
<input
type="text"
readonly
value={httpUrl(repo.name)}
class="clone-url-input"
/>
</div>
{!config.SSH_DISABLED && (
<div class="tree-toolbar-actions">
{branches.includes(repo.default_branch) &&
user?.isAdmin && (
<a
href={`/${repo.name}/new-file/${repo.default_branch}`}
class="btn btn-sm btn-primary"
>
New file
</a>
)}
<details class="clone-popup-details">
<summary class="btn btn-sm btn-primary">
Clone
</summary>
<div class="clone-popup">
<div class="clone-url-row">
<span class="clone-url-label">
SSH
HTTP
</span>
<input
type="text"
readonly
value={sshUrl(repo.name)}
value={httpUrl(repo.name)}
class="clone-url-input"
/>
</div>
)}
</div>
</details>
{!config.SSH_DISABLED && (
<div class="clone-url-row">
<span class="clone-url-label">
SSH
</span>
<input
type="text"
readonly
value={sshUrl(repo.name)}
class="clone-url-input"
/>
</div>
)}
</div>
</details>
</div>
</div>
<FileTreeTable
repoName={repo.name}
Asrc/views/repos/TagList.tsx
@@ -0,0 +1,224 @@
import type { RepositoryRow } from "../../db/index.ts";
import { formatDateTime } from "../../lib/formatDate.ts";
import type { SessionUser } from "../../middleware/session.ts";
import type { TagInfo } from "../../services/git.ts";
import { Layout } from "../layout.tsx";
import { Pagination } from "../Pagination.tsx";
import { RepoHeader } from "./RepoHeader.tsx";
import { RepoNav } from "./RepoNav.tsx";
interface TagListProps {
user: SessionUser | null;
repo: RepositoryRow;
tags: TagInfo[];
tagReleaseMap: Map<string, number>;
page: number;
totalPages: number;
success?: string;
error?: string;
}
export function TagList({
user,
repo,
tags,
tagReleaseMap,
page,
totalPages,
success,
error,
}: TagListProps) {
return (
<Layout user={user} title={`Tags — ${repo.name}`}>
<div class="container">
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="tags" user={user} />
{success && <p class="form-success">{success}</p>}
{error && <p class="form-error">{error}</p>}
<div class="list-header">
<h2 class="list-heading">Tags</h2>
{user?.isAdmin && (
<details class="confirm-details">
<summary class="btn btn-primary btn-sm">
New tag
</summary>
<div class="confirm-popup confirm-popup-form">
<form
method="POST"
action={`/${repo.name}/tags/create`}
class="popup-form"
>
<div class="form-group">
<label for="new-tag-name">
Tag name
</label>
<input
id="new-tag-name"
name="name"
type="text"
required
placeholder="v1.0.0"
maxlength="255"
/>
</div>
<div class="form-group">
<label for="new-tag-ref">Target</label>
<input
id="new-tag-ref"
name="ref"
type="text"
required
value={repo.default_branch}
placeholder="branch, tag, or commit"
maxlength="255"
/>
</div>
<div class="form-group">
<label for="new-tag-message">
Message{" "}
<span class="text-muted">
(blank = lightweight)
</span>
</label>
<input
id="new-tag-message"
name="message"
type="text"
placeholder="Optional tag message"
maxlength="500"
/>
</div>
<button
type="submit"
class="btn btn-primary btn-sm"
>
Create tag
</button>
</form>
</div>
</details>
)}
</div>
{tags.length === 0 ? (
<div class="empty-state">
<p>No tags yet.</p>
</div>
) : (
<ul class="ref-list">
{tags.map((tag) => {
const releaseId = tagReleaseMap.get(tag.name);
return (
<li class="ref-item">
<div class="ref-name-row">
<a
href={`/${repo.name}/tree/${tag.name}`}
class="ref-name"
>
{tag.name}
</a>
{releaseId && (
<a
href={`/${repo.name}/releases/${releaseId}`}
class="badge badge-release"
>
release
</a>
)}
</div>
<div class="ref-meta-row">
{tag.taggerName && (
<span class="ref-author">
{tag.taggerName}
</span>
)}
{tag.shortHash && (
<a
href={`/${repo.name}/commit/${tag.shortHash}`}
class="ref-hash mono"
>
{tag.shortHash}
</a>
)}
{tag.subject && (
<span class="ref-subject">
{tag.subject}
</span>
)}
{tag.date && (
<time
class="ref-date"
datetime={tag.date}
>
{formatDateTime(tag.date)}
</time>
)}
{user?.isAdmin && (
<span class="ref-actions">
<details class="confirm-details confirm-details-inline">
<summary class="btn btn-sm btn-danger">
Delete
</summary>
<div class="confirm-popup">
{releaseId ? (
<p class="confirm-warning">
Tag{" "}
<strong>
{tag.name}
</strong>{" "}
is linked to{" "}
<a
href={`/${repo.name}/releases/${releaseId}`}
>
a release
</a>
. Only the git
tag will be
deleted; the
release record
will remain.
</p>
) : (
<>
Delete tag{" "}
<strong>
{tag.name}
</strong>
?
</>
)}
<form
method="POST"
action={`/${repo.name}/tags/delete`}
class="inline-form"
>
<input
type="hidden"
name="name"
value={tag.name}
/>
<button
type="submit"
class="btn btn-sm btn-danger"
>
Yes, delete
</button>
</form>
</div>
</details>
</span>
)}
</div>
</li>
);
})}
</ul>
)}
<Pagination
page={page}
totalPages={totalPages}
pageUrlTemplate={`/${repo.name}/tags?page={page}`}
/>
</div>
</Layout>
);
}
Atests/e2e.git-ops.test.ts
@@ -0,0 +1,481 @@
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { chromium } from 'playwright';
import type { Browser, BrowserContext } from 'playwright';
import {
ADMIN_PASS,
BASE,
DATA_DIR,
getHeadCommit,
gitOutput,
killServer,
login,
seedBranch,
seedRepo,
setupTestEnv,
spawnServer,
} from './helpers.ts';
let browser: Browser;
let server: Awaited<ReturnType<typeof spawnServer>>;
let adminCtx: BrowserContext;
beforeAll(async () => {
await setupTestEnv();
server = await spawnServer();
browser = await chromium.launch();
// Single admin context shared across all describes
adminCtx = await browser.newContext();
const page = await adminCtx.newPage();
await login(page, 'admin', ADMIN_PASS);
await page.close();
// Create all repos upfront
for (const name of ['branch-repo', 'tag-repo', 'selector-repo', 'newfile-repo', 'delfile-repo', 'movefile-repo']) {
const p = await adminCtx.newPage();
try {
await p.goto(`${BASE}/new`);
await p.fill('[name=name]', name);
await p.click('form[action="/new"] button[type=submit]');
await p.waitForURL(`${BASE}/${name}`);
} finally { await p.close(); }
}
// Seed repos
await seedRepo('branch-repo');
seedBranch('branch-repo', 'feature-a');
await seedRepo('tag-repo');
gitOutput(['-C', repoDir('tag-repo'), 'tag', 'v0.1.0', 'HEAD']);
await seedRepo('selector-repo');
gitOutput(['-C', repoDir('selector-repo'), 'tag', 'stable', 'HEAD']);
await seedRepo('newfile-repo');
await seedRepo('delfile-repo');
await seedRepo('movefile-repo');
});
afterAll(async () => {
await adminCtx.close();
await browser.close();
await killServer(server);
});
function repoDir(name: string) {
return `${process.cwd()}/${DATA_DIR}/repos/${name}.git`;
}
// ─── Branch management ────────────────────────────────────────────────────────
describe('branches', () => {
test('Branches tab appears in repo nav', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/branch-repo`);
const tab = page.locator('a.repo-tab', { hasText: 'Branches' });
expect(await tab.isVisible()).toBe(true);
} finally { await page.close(); }
});
test('branches page lists all branches', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/branch-repo/branches`);
const content = await page.content();
expect(content).toContain('main');
expect(content).toContain('feature-a');
} finally { await page.close(); }
});
test('default branch has a "default" badge', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/branch-repo/branches`);
expect(await page.locator('.badge', { hasText: 'default' }).count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
test('branch list shows last commit hash and subject', async () => {
const sha = getHeadCommit('branch-repo').slice(0, 7);
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/branch-repo/branches`);
const content = await page.content();
expect(content).toContain(sha);
expect(content).toContain('Initial commit');
} finally { await page.close(); }
});
test('branches page returns 404 for non-existent repo', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
const resp = await page.request.get(`${BASE}/does-not-exist/branches`);
expect(resp.status()).toBe(404);
} finally {
await page.close();
await ctx.close();
}
});
test('create a new branch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/branch-repo/branches`);
await page.click('details:has(summary:text("New branch")) summary');
await page.fill('[name=name]', 'new-feature');
await page.fill('[name=source_ref]', 'main');
await page.click('form[action*="branches/create"] button[type=submit]');
await page.waitForURL(/branches/);
expect(await page.locator('.ref-item').filter({ hasText: 'new-feature' }).count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
test('creating branch with invalid name shows error', async () => {
const resp = await adminCtx.request.post(`${BASE}/branch-repo/branches/create`, {
form: { name: '--invalid', source_ref: 'main' },
});
expect(resp.url()).toContain('error');
});
test('creating branch from non-existent ref shows error', async () => {
const resp = await adminCtx.request.post(`${BASE}/branch-repo/branches/create`, {
form: { name: 'bad-branch', source_ref: 'does-not-exist' },
});
expect(resp.url()).toContain('error');
});
test('rename a branch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/branch-repo/branches`);
const row = page.locator('.ref-item').filter({ hasText: 'feature-a' });
await row.locator('details:has(summary:text("Rename")) summary').click();
await row.locator('[name=new_name]').fill('feature-renamed');
await row.locator('form[action*="branches/rename"] button[type=submit]').click();
await page.waitForURL(/branches/);
expect(await page.locator('.ref-item').filter({ hasText: 'feature-renamed' }).count()).toBeGreaterThan(0);
expect(await page.locator('.ref-item').filter({ hasText: 'feature-a' }).count()).toBe(0);
} finally { await page.close(); }
});
test('delete a non-default branch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/branch-repo/branches`);
const row = page.locator('.ref-item').filter({ hasText: 'new-feature' });
await row.locator('details:has(summary:text("Delete")) summary').click();
await row.locator('form[action*="branches/delete"] button[type=submit]').click();
await page.waitForURL(/branches/);
expect(await page.locator('.ref-item').filter({ hasText: 'new-feature' }).count()).toBe(0);
} finally { await page.close(); }
});
test('cannot delete the default branch (no Delete button on main row)', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/branch-repo/branches`);
const mainRow = page.locator('.ref-item').filter({ hasText: 'main' });
expect(await mainRow.locator('summary:text("Delete")').count()).toBe(0);
} finally { await page.close(); }
});
test('renaming default branch updates it in repo', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/branch-repo/branches`);
const row = page.locator('.ref-item').filter({ hasText: 'main' });
await row.locator('details:has(summary:text("Rename")) summary').click();
await row.locator('[name=new_name]').fill('trunk');
await row.locator('form[action*="branches/rename"] button[type=submit]').click();
await page.waitForURL(/branches/);
expect(await page.locator('.ref-item').filter({ hasText: 'trunk' }).locator('.badge', { hasText: 'default' }).count()).toBeGreaterThan(0);
// Rename back
await page.goto(`${BASE}/branch-repo/branches`);
const trunkRow = page.locator('.ref-item').filter({ hasText: 'trunk' });
await trunkRow.locator('details:has(summary:text("Rename")) summary').click();
await trunkRow.locator('[name=new_name]').fill('main');
await trunkRow.locator('form[action*="branches/rename"] button[type=submit]').click();
await page.waitForURL(/branches/);
} finally { await page.close(); }
});
});
// ─── Tag management ───────────────────────────────────────────────────────────
describe('tags', () => {
test('Tags tab appears in repo nav', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/tag-repo`);
const tab = page.locator('a.repo-tab', { hasText: 'Tags' });
expect(await tab.isVisible()).toBe(true);
} finally { await page.close(); }
});
test('tags page lists existing tags', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/tag-repo/tags`);
expect(await page.content()).toContain('v0.1.0');
} finally { await page.close(); }
});
test('tag links to correct tree view', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/tag-repo/tags`);
const link = page.locator(`a[href="/${['tag-repo', 'tree', 'v0.1.0'].join('/')}"]`);
expect(await link.count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
test('create a new tag', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/tag-repo/tags`);
await page.click('details:has(summary:text("New tag")) summary');
await page.fill('[name=name]', 'v1.0.0');
await page.click('form[action*="tags/create"] button[type=submit]');
await page.waitForURL(/tags/);
expect(await page.locator('.ref-item').filter({ hasText: 'v1.0.0' }).count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
test('create annotated tag with message', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/tag-repo/tags`);
await page.click('details:has(summary:text("New tag")) summary');
await page.fill('[name=name]', 'v1.1.0-annotated');
await page.fill('[name=message]', 'Annotated release tag');
await page.click('form[action*="tags/create"] button[type=submit]');
await page.waitForURL(/tags/);
expect(await page.locator('.ref-item').filter({ hasText: 'v1.1.0-annotated' }).count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
test('delete a tag', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/tag-repo/tags`);
const row = page.locator('.ref-item').filter({ hasText: 'v1.0.0' });
await row.locator('details:has(summary:text("Delete")) summary').click();
await row.locator('form[action*="tags/delete"] button[type=submit]').click();
await page.waitForURL(/tags/);
expect(await page.locator('.ref-item').filter({ hasText: 'v1.0.0' }).count()).toBe(0);
expect(await page.locator('.ref-item').filter({ hasText: 'v0.1.0' }).count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
test('tag linked to release shows release badge and warning on delete', async () => {
const resp = await adminCtx.request.post(`${BASE}/tag-repo/releases`, {
multipart: { name: 'Linked Release', create_tag: 'on', tag_name: 'v-linked', revision: 'main' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/tag-repo/tags`);
const row = page.locator('.ref-item').filter({ hasText: 'v-linked' });
expect(await row.locator('.badge-release').count()).toBeGreaterThan(0);
await row.locator('details:has(summary:text("Delete")) summary').click();
expect(await row.locator('.confirm-warning').count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
});
// ─── BranchSelector with tags ─────────────────────────────────────────────────
describe('BranchSelector tags integration', () => {
test('branch selector shows Tags optgroup when tags exist', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/selector-repo`);
expect(await page.locator('optgroup[label="Tags"]').count()).toBe(1);
expect(await page.locator('option', { hasText: 'stable' }).count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
test('branch selector shows Branches optgroup when tags exist', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/selector-repo`);
expect(await page.locator('optgroup[label="Branches"]').count()).toBe(1);
} finally { await page.close(); }
});
test('branch selector on blob view includes tag optgroup', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/selector-repo/blob/main/README.md`);
expect(await page.locator('optgroup[label="Tags"]').count()).toBe(1);
} finally { await page.close(); }
});
});
// ─── File creation ────────────────────────────────────────────────────────────
describe('file creation', () => {
test('New file button appears in tree toolbar for admin on a branch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/newfile-repo/tree/main`);
expect(await page.locator('a[href*="/new-file/main"]').count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
test('New file button not visible on commit SHA view', async () => {
const sha = getHeadCommit('newfile-repo');
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/newfile-repo/tree/${sha}`);
expect(await page.locator('a[href*="/new-file/"]').count()).toBe(0);
} finally { await page.close(); }
});
test('New file button not visible to unauthenticated user', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/newfile-repo/tree/main`);
expect(await page.locator('a[href*="/new-file/main"]').count()).toBe(0);
} finally {
await page.close();
await ctx.close();
}
});
test('new file form pre-fills dir when ?dir= query param is provided', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/newfile-repo/new-file/main?dir=src`);
const pathInput = await page.locator('[name=path]').inputValue();
expect(pathInput).toBe('src/');
} finally { await page.close(); }
});
test('creating a new file creates a commit and redirects to commit view', async () => {
const resp = await adminCtx.request.post(`${BASE}/newfile-repo/new-file/main`, {
form: { path: 'hello.txt', content: 'Hello, world!\n', message: 'Add hello.txt' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
expect(resp.headers()['location']).toMatch(/\/newfile-repo\/commit\/[0-9a-f]{40}/);
});
test('new file appears in tree after creation', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/newfile-repo/tree/main`);
expect(await page.content()).toContain('hello.txt');
} finally { await page.close(); }
});
test('new file commit is signed', async () => {
const hash = gitOutput(['-C', repoDir('newfile-repo'), 'log', '--format=%H', '--grep=Add hello.txt', '-1']);
expect(hash).toBeTruthy();
const obj = gitOutput(['-C', repoDir('newfile-repo'), 'cat-file', '-p', hash]);
expect(obj).toContain('gpgsig');
});
test('creating file with invalid path shows error', async () => {
const resp = await adminCtx.request.post(`${BASE}/newfile-repo/new-file/main`, {
form: { path: '../escape', content: '', message: 'bad' },
});
expect(resp.url()).toContain('error');
});
});
// ─── File deletion ────────────────────────────────────────────────────────────
describe('file deletion', () => {
test('Delete button appears in file blob for admin on a branch', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/delfile-repo/blob/main/index.js`);
expect(await page.locator('details:has(summary:text("Delete"))').count()).toBeGreaterThan(0);
} finally { await page.close(); }
});
test('Delete button not visible to unauthenticated user', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/delfile-repo/blob/main/index.js`);
expect(await page.locator('details:has(summary:text("Delete"))').count()).toBe(0);
} finally {
await page.close();
await ctx.close();
}
});
test('deleting a file creates a commit and removes it from the tree', async () => {
const resp = await adminCtx.request.post(`${BASE}/delfile-repo/delete-file/main/index.js`, {
form: { message: 'Remove index.js' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
expect(resp.headers()['location']).toMatch(/\/delfile-repo\/commit\/[0-9a-f]{40}/);
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/delfile-repo/tree/main`);
expect(await page.content()).not.toContain('index.js');
} finally { await page.close(); }
});
test('delete commit is signed', async () => {
const hash = gitOutput(['-C', repoDir('delfile-repo'), 'log', '--format=%H', '--grep=Remove index.js', '-1']);
expect(hash).toBeTruthy();
const obj = gitOutput(['-C', repoDir('delfile-repo'), 'cat-file', '-p', hash]);
expect(obj).toContain('gpgsig');
});
});
// ─── File rename/move ─────────────────────────────────────────────────────────
describe('file rename/move', () => {
test('edit form has new_path input pre-filled with current path', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/movefile-repo/edit/main/index.js`);
const newPathInput = await page.locator('[name=new_path]').inputValue();
expect(newPathInput).toBe('index.js');
} finally { await page.close(); }
});
test('renaming a file via edit creates a commit and old path is gone', async () => {
const resp = await adminCtx.request.post(`${BASE}/movefile-repo/edit/main/index.js`, {
form: { content: 'console.log("hello");\n', new_path: 'app.js', message: 'Rename index.js to app.js' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
expect(resp.headers()['location']).toMatch(/\/movefile-repo\/commit\/[0-9a-f]{40}/);
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/movefile-repo/tree/main`);
expect(await page.content()).toContain('app.js');
expect(await page.content()).not.toContain('index.js');
} finally { await page.close(); }
});
test('rename commit is signed', async () => {
const hash = gitOutput(['-C', repoDir('movefile-repo'), 'log', '--format=%H', '--grep=Rename index.js', '-1']);
expect(hash).toBeTruthy();
const obj = gitOutput(['-C', repoDir('movefile-repo'), 'cat-file', '-p', hash]);
expect(obj).toContain('gpgsig');
});
test('renaming to invalid path shows error', async () => {
const resp = await adminCtx.request.post(`${BASE}/movefile-repo/edit/main/README.md`, {
form: { content: '# movefile-repo\n', new_path: '../escape.md', message: 'bad' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
expect(resp.headers()['location']).toContain('error');
});
});