GitFOSS
feat(repository): make the Fork feature to work!
+ 382
- 19
@@ -1,5 +1,5 @@
 {
-  "_generatedAtUnix": 1664235996567,
+  "_generatedAtUnix": 1664245360775,
   "_hashAlgorithm": "sha1",
   "_version": 2,
   "islands": {

...
@@ -39,6 +39,12 @@
       "pathBundle": "./public/.islands/RepositoryFilesDiffsList.bundle.js",
       "pathSourceMap": "./public/.islands/RepositoryFilesDiffsList.bundle.js.map"
     },
+    "RepositoryForkForm": {
+      "hash": "b2a18179c18f3d6baa734c0a063f5f31ec3f2f08",
+      "pathSource": "./app/islands/RepositoryForkForm.tsx",
+      "pathBundle": "./public/.islands/RepositoryForkForm.bundle.js",
+      "pathSourceMap": "./public/.islands/RepositoryForkForm.bundle.js.map"
+    },
     "RepositoryInitialSetup": {
       "hash": "8ac1ef808e4d5c92290f7f917654f9668e06902e",
       "pathSource": "./app/islands/RepositoryInitialSetup.tsx",

...
@@ -98,7 +104,7 @@
       "pathSource": "./app/views/repository/RepositoryExploreView.tsx"
     },
     "RepositoryForkView": {
-      "hash": "9056ba5ee5c40a9525188a44134bf35e621cba70",
+      "hash": "88a61a5f277217b5b4e29f9d228adec1c9a2fdc7",
       "pathSource": "./app/views/repository/RepositoryForkView.tsx"
     },
     "RepositoryShowObjectView": {

app/controllers/repository/getRepositoryForkView.ts
@@ -69,6 +69,11 @@ const getRepositoryForkView: ReqHandler = async (request, reply) => {
     availableParentOrgs,
     errorMessage,
     isForkable,
+    initialValues: {
+      target_repo_display_name: sourceRepo.displayName,
+      target_repo_slug: sourceRepo.slug,
+      target_repo_visibility: sourceRepo.visibility,
+    },
     sourceRepo,
     sourceParentOrg,
   });

app/controllers/repository/index.ts
@@ -7,6 +7,7 @@ import { default as getRepositoryExploreView } from "./getRepositoryExploreView"
 import { default as getRepositoryForkView } from "./getRepositoryForkView";
 import { default as getRepositoryShowObjectView } from "./getRepositoryShowObjectView";
 import { default as postRepositoryCreateAction } from "./postRepositoryCreateAction";
+import { default as postRepositoryForkAction } from "./postRepositoryForkAction";
 
 export const RepositoryController = {
   getRepositoryBrowserView,

...
@@ -18,4 +19,5 @@ export const RepositoryController = {
   getRepositoryForkView,
   getRepositoryShowObjectView,
   postRepositoryCreateAction,
+  postRepositoryForkAction,
 };

app/controllers/repository/postRepositoryCreateAction.ts
@@ -13,7 +13,7 @@ import RepositoryCreateView, {
   RepositoryCreateViewProps,
 } from "../../views/repository/RepositoryCreateView";
 
-const getRepositoryCreateAction: ReqHandler = async (request, reply) => {
+const postRepositoryCreateAction: ReqHandler = async (request, reply) => {
   if (
     request.session.data.authenticated === false ||
     request.session.data.curr_user_uid == null

...
@@ -79,4 +79,4 @@ const getRepositoryCreateAction: ReqHandler = async (request, reply) => {
   return reply;
 };
 
-export default getRepositoryCreateAction;
+export default postRepositoryCreateAction;

new file
app/controllers/repository/postRepositoryForkAction.ts
@@ -0,0 +1,120 @@
+// std
+import { join, resolve } from "node:path";
+// 1st-party
+import type { ReqHandler } from "@ethicdevs/react-monolith";
+// app
+import { AppRoute, AppRoutesParams } from "../../routes";
+import { Env } from "../../env";
+// app services
+import { makeOrganizationService } from "../../services/organization";
+import { makeRepositoryService } from "../../services/repository";
+import { makeUsersService } from "../../services/user";
+// app views
+import RepositoryForkView, {
+  RepositoryForkViewProps,
+} from "../../views/repository/RepositoryForkView";
+
+const postRepositoryForkAction: ReqHandler = async (request, reply) => {
+  if (
+    request.session.data.authenticated === false ||
+    request.session.data.curr_user_uid == null
+  ) {
+    reply.redirect(302, request.namedViewsPathMap[AppRoute.AUTH_LOGIN]);
+    return reply;
+  }
+
+  const reqHandler = reply.makeRequestHandler(request, reply);
+  const organizationService = makeOrganizationService({ request });
+  const repoService = makeRepositoryService({ request });
+  const usersService = makeUsersService({ request });
+
+  const { body, params, validationError } = request;
+
+  const { orgSlug: sourceOrgSlug, repoSlug: sourceRepoSlug } =
+    params as AppRoutesParams[AppRoute.REPOSITORY_FORK_ACTION]["params"];
+
+  const {
+    target_org_slug: targetOrgSlug,
+    target_repo_slug: targetRepoSlug,
+    target_repo_display_name: targetRepoDisplayName,
+    target_repo_visibility: targetRepoVisibility,
+  } = body as AppRoutesParams[AppRoute.REPOSITORY_FORK_ACTION]["body"];
+
+  const sourceParentOrg = await organizationService.getOrganizationBySlug(
+    sourceOrgSlug
+  );
+
+  if (sourceParentOrg == null) {
+    return reply.status(404).callNotFound();
+  }
+
+  const sourceRepo = await repoService.getRepository(
+    sourceOrgSlug,
+    sourceRepoSlug
+  );
+
+  if (sourceRepo == null) {
+    return reply.status(404).callNotFound();
+  }
+
+  const targetParentOrg = await organizationService.getOrganizationBySlug(
+    targetOrgSlug
+  );
+
+  if (targetParentOrg == null) {
+    return reply.status(404).callNotFound();
+  }
+
+  const availableParentOrgs = await usersService.getUserOrganizations(
+    request.session.data.curr_user_uid
+  );
+
+  if (validationError != null) {
+    const { message: errorMessage } = validationError;
+    return reqHandler<RepositoryForkViewProps>(RepositoryForkView.name, {
+      availableParentOrgs,
+      sourceParentOrg,
+      sourceRepo,
+      errorMessage,
+      initialValues:
+        body as AppRoutesParams[AppRoute.REPOSITORY_FORK_ACTION]["body"],
+    });
+  }
+
+  try {
+    const newRepo = await repoService.forkRepository({
+      source: {
+        parentOrg: sourceParentOrg,
+        parentOrgRepositoriesDir: resolve(
+          join(Env.GIT_REPOSITORIES_ROOT, sourceParentOrg.slug)
+        ),
+        repository: sourceRepo,
+      },
+      target: {
+        parentOrg: targetParentOrg,
+        parentOrgRepositoriesDir: resolve(
+          join(Env.GIT_REPOSITORIES_ROOT, targetParentOrg.slug)
+        ),
+        repoSlug: targetRepoSlug,
+        repoData: {
+          displayName: targetRepoDisplayName,
+          visibility: targetRepoVisibility,
+        },
+      },
+    });
+
+    reply.redirect(302, `/${targetParentOrg.slug}/${newRepo.slug}`);
+    return reply;
+  } catch (err) {
+    return reqHandler<RepositoryForkViewProps>(RepositoryForkView.name, {
+      availableParentOrgs,
+      sourceParentOrg,
+      sourceRepo,
+      errorMessage: (err as Error).message,
+      initialValues:
+        body as AppRoutesParams[AppRoute.REPOSITORY_FORK_ACTION]["body"],
+    });
+  }
+};
+
+export default postRepositoryForkAction;

new file
app/islands/RepositoryForkForm.tsx
@@ -0,0 +1,194 @@
+// 1st-party
+import type { ReactIsland } from "@ethicdevs/react-monolith";
+// 3rd-party
+import React, { useCallback, useEffect, useState } from "react";
+// generated via script[generate:prisma]
+import type { Organization, ResourceVisibility } from "@prisma/client";
+// app
+import { Button } from "../components/Button.styled";
+import { Grid } from "../components/Grid";
+import { slugify } from "../utils/shared";
+
+export interface RepositoryForkFormProps {
+  disabled?: boolean;
+  availableParentOrgs: Organization[];
+  editMode?: boolean;
+  initialValues?: {
+    target_org_slug: string;
+    target_repo_display_name: string;
+    target_repo_slug: string;
+    target_repo_visibility: ResourceVisibility;
+  };
+}
+
+const RepositoryForkForm: ReactIsland<RepositoryForkFormProps> = ({
+  availableParentOrgs,
+  disabled = false,
+  editMode = false,
+  initialValues = undefined,
+}) => {
+  const [displayName, setDisplayName] = useState<string>(
+    initialValues?.target_repo_display_name || ""
+  );
+  const [slug, setSlug] = useState<string>(
+    initialValues?.target_repo_slug ||
+      slugify(initialValues?.target_repo_display_name || "")
+  );
+  const [slugInputDirty, setSlugInputDirty] = useState<boolean>(false);
+
+  editMode;
+
+  const onDisplayNameInputChange = useCallback(
+    (ev: React.ChangeEvent<HTMLInputElement>) => {
+      if (ev == null || ev.target == null || ev.target.value == null) {
+        return undefined;
+      }
+      setDisplayName(ev.target.value);
+      return undefined;
+    },
+    [setDisplayName]
+  );
+
+  const onSlugInputChange = useCallback(
+    (ev: React.ChangeEvent<HTMLInputElement>) => {
+      if (ev == null || ev.target == null || ev.target.value == null) {
+        return undefined;
+      }
+      setSlug(ev.target.value);
+      // only once, avoid useless re-renders for nothing
+      if (slugInputDirty === false) {
+        setSlugInputDirty(true);
+      }
+      return undefined;
+    },
+    [slugInputDirty, setSlug, setSlugInputDirty]
+  );
+
+  // An effect that will update the slug if it isn't dirty when the display name
+  // input value (displayName) has changed.
+  useEffect(() => {
+    const nextSlug = slugify(displayName);
+    if (
+      slugInputDirty === false &&
+      displayName != null &&
+      displayName.trim() !== "" &&
+      nextSlug !== slug
+    ) {
+      setSlug(nextSlug);
+    }
+  }, [displayName, slug, slugInputDirty, setSlug]);
+
+  return (
+    <div>
+      <fieldset>
+        <legend>Fork details</legend>
+        {/* Repository Name */}
+        <Grid.Col fluid nowrap>
+          <label htmlFor={"target_repo_display_name"}>
+            Repository Name <span>(*)</span>:
+          </label>
+          <input
+            disabled={disabled}
+            name={"target_repo_display_name"}
+            onChange={onDisplayNameInputChange}
+            placeholder={"i.e. My Super Project"}
+            required
+            style={styles.inputMaxWidth}
+            type={"text"}
+            value={displayName}
+          />
+        </Grid.Col>
+        <Grid.Row fluid nowrap alignItems={"center"}>
+          {/* Parent Organization Select */}
+          <Grid.Col fluid nowrap>
+            <label htmlFor={"target_org_slug"}>
+              Owner Organization <span>(*)</span>:
+            </label>
+            <select
+              disabled={disabled}
+              defaultValue={
+                availableParentOrgs.length >= 1
+                  ? availableParentOrgs[0].slug
+                  : initialValues?.target_org_slug
+              }
+              name={"target_org_slug"}
+              required
+              style={styles.inputMaxWidth}
+            >
+              {availableParentOrgs.map((org) => (
+                <option key={org.id} value={org.slug}>
+                  {org.displayName || org.slug}
+                </option>
+              ))}
+            </select>
+          </Grid.Col>
+          {/* Repository Slug */}
+          <Grid.Col fluid nowrap>
+            <label htmlFor={"target_repo_slug"}>
+              Repository Slug <span>(*)</span>:
+            </label>
+            <input
+              disabled={disabled}
+              name={"target_repo_slug"}
+              onChange={onSlugInputChange}
+              placeholder={"i.e. my-super-project"}
+              required
+              style={styles.inputMaxWidth}
+              type={"text"}
+              value={slug}
+            />
+          </Grid.Col>
+        </Grid.Row>
+        {/* Repository Visibility */}
+        <Grid.Col fluid nowrap>
+          <label htmlFor={"target_repo_visibility"}>
+            Repository Visibility <span>(*)</span>:
+          </label>
+          <select
+            disabled={disabled}
+            defaultValue={initialValues?.target_repo_visibility || "PRIVATE"}
+            name={"target_repo_visibility"}
+            style={styles.inputMaxWidth}
+          >
+            <option key={"private"} value={"PRIVATE"}>
+              Private
+            </option>
+            <option key={"unlisted"} value={"UNLISTED"}>
+              Unlisted
+            </option>
+            <option key={"public"} value={"PUBLIC"}>
+              Public
+            </option>
+          </select>
+        </Grid.Col>
+      </fieldset>
+      {/* Submit Button */}
+      <Button style={styles.inputMaxWidth} type={"submit"} disabled={disabled}>
+        Fork Repository!
+      </Button>
+    </div>
+  );
+};
+
+const styles = {
+  alignSelfEnd: {
+    alignSelf: "flex-end",
+  },
+  inputMaxWidth: {
+    minWidth: "100%",
+    width: "100%",
+    maxWidth: "100%",
+  },
+  labelFlexOne: {
+    flex: 1,
+    marginRight: 8,
+  },
+  shortDescriptionTextArea: {
+    minHeight: 75,
+    height: 75,
+    maxHeight: 75,
+  },
+};
+
+RepositoryForkForm.displayName = "RepositoryForkForm";
+export default RepositoryForkForm;

@@ -578,6 +578,12 @@ const RootAppRouter: AppRouter = () => {
           path={"/:orgSlug/:repoSlug/fork"}
           handler={RepositoryController.getRepositoryForkView}
         />
+        <Router.Route
+          name={AppRoute.REPOSITORY_FORK_ACTION}
+          method={"POST"}
+          path={"/:orgSlug/:repoSlug/fork"}
+          handler={RepositoryController.postRepositoryForkAction}
+        />
         <Router.Route
           name={AppRoute.REPOSITORY_SHOW_OBJECT}
           method={"GET"}

app/services/repository/forkRepository.ts
@@ -1,9 +1,12 @@
 // std
 import { existsSync } from "node:fs";
-import { copyFile, mkdir } from "node:fs/promises";
 import { join, resolve } from "node:path";
+import { mkdir } from "node:fs/promises";
+import { spawn } from "node:child_process";
 // 1st-party
 import type { ServiceMethodFactory } from "@ethicdevs/react-monolith";
+// 3rd-party
+import cuid from "cuid";
 // generated via script[generate:prisma]
 import type { Repository } from "@prisma/client";
 // service

...
@@ -59,9 +62,15 @@ const makeForkRepository: ServiceMethodFactory<
 
     const newRepo = await request.prisma.repository.create({
       data: {
+        ...source.repository,
         ...target.repoData,
+        id: cuid(), // to generate one
+        createdAt: new Date(Date.now()),
+        updatedAt: new Date(Date.now()),
         organizationId: target.parentOrg.id,
         slug: target.repoSlug,
+        isFork: true,
+        forkedFromRepoId: source.repository.id,
       },
     });
 

...
@@ -110,7 +119,33 @@ const makeForkRepository: ServiceMethodFactory<
       targetRepositoryPathResolved
     );
 
-    await copyFile(sourceRepositoryPathResolved, targetRepositoryPathResolved);
+    // could be much simpler... but does not work thanks to bug in libuv ->
+    // https://github.com/nodejs/node/issues/36439#issuecomment-765403311
+    // await copyFile(sourceRepositoryPathResolved, targetRepositoryPathResolved);
+
+    const gitCopyForkRepoProcess = spawn("/bin/cp", [
+      "-rf",
+      sourceRepositoryPathResolved,
+      targetRepositoryPathResolved,
+    ]);
+
+    const gitCopyForkRepoResult = await new Promise<string>(
+      (resolve, reject) => {
+        let buffer = [] as string[];
+        gitCopyForkRepoProcess.stdout.on("data", (data) => buffer.push(data));
+        gitCopyForkRepoProcess.stderr.on("data", (data) => {
+          reject(new Error(Buffer.from(data).toString("utf-8")));
+        });
+        gitCopyForkRepoProcess.stdout.on("close", () => {
+          resolve(buffer.join(""));
+        });
+      }
+    );
+
+    console.log(
+      `[ok] finished execution of "cp -rf ${sourceRepositoryPathResolved} ${targetRepositoryPathResolved}" with result:\n\t`,
+      gitCopyForkRepoResult
+    );
 
     console.log(
       `[ok] forked repository folder from:`,

app/services/user/getUserOrganizations.ts
@@ -26,6 +26,9 @@ const getUserOrganizations: ServiceMethodFactory<
           },
         ],
       },
+      orderBy: {
+        kind: "asc", // private first then company
+      },
     });
 
     return userOrgs;

app/views/repository/RepositoryForkView.tsx
@@ -8,7 +8,7 @@ import { Organization, Repository, ResourceVisibility } from "@prisma/client";
 import type { CommonProps } from "../../types";
 import { Layout, PageWrapper } from "../../components";
 // app islands
-// import RepositoryCreateForm from "../../islands/RepositoryCreateForm";
+import RepositoryForkForm from "../../islands/RepositoryForkForm";
 
 export interface RepositoryForkViewProps extends CommonProps {
   availableParentOrgs: Organization[];

...
@@ -29,6 +29,7 @@ const RepositoryForkView: ReactView<RepositoryForkViewProps> = ({
   sourceParentOrg,
   sourceRepo,
   errorMessage = undefined,
+  initialValues = undefined,
 }) => {
   return (
     <Layout {...commonProps}>

...
@@ -54,21 +55,12 @@ const RepositoryForkView: ReactView<RepositoryForkViewProps> = ({
           action={`/${sourceParentOrg.slug}/${sourceRepo.slug}/fork`}
           method={"POST"}
         >
-          <pre>
-            <code>{JSON.stringify(availableParentOrgs, null, 2)}</code>
-          </pre>
-          <pre>
-            <code>{JSON.stringify(sourceParentOrg, null, 2)}</code>
-          </pre>
-          <pre>
-            <code>{JSON.stringify(sourceRepo, null, 2)}</code>
-          </pre>
-          {/*<div data-islandid={`${RepositoryCreateForm.name}$$0`}>
-            <RepositoryCreateForm
+          <div data-islandid={`${RepositoryForkForm.name}$$0`}>
+            <RepositoryForkForm
               availableParentOrgs={availableParentOrgs}
               initialValues={initialValues}
             />
-          </div>*/}
+          </div>
         </form>
       </PageWrapper>
     </Layout>