From d3c1a8148580e1869c91ee6caadda17165ceb0ea Mon Sep 17 00:00:00 2001 From: Laura Koye Date: Wed, 19 Aug 2026 11:15:42 -0500 Subject: [PATCH 1/7] [DIT-13461]: Read git context in the CLI scan --- lib/src/scan/git.test.ts | 114 +++++++++++++++++++++++++++++++++++++++ lib/src/scan/git.ts | 101 ++++++++++++++++++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 lib/src/scan/git.test.ts create mode 100644 lib/src/scan/git.ts diff --git a/lib/src/scan/git.test.ts b/lib/src/scan/git.test.ts new file mode 100644 index 0000000..9bded3e --- /dev/null +++ b/lib/src/scan/git.test.ts @@ -0,0 +1,114 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { normalizeRepoKey, readGitContext } from "./git"; + +const REPO_KEY_PATTERN = + /^[a-z0-9.-]+(\/[a-z0-9._-]+){1,}\/(?!.*\.git$)[a-z0-9._-]+$/; + +describe("normalizeRepoKey", () => { + const cases: [string, string | null][] = [ + ["git@github.com:Ditto/App.git", "github.com/ditto/app"], + ["https://github.com/ditto/app", "github.com/ditto/app"], + ["https://github.com/ditto/app.git", "github.com/ditto/app"], + ["ssh://git@github.com:2222/ditto/app.git", "github.com/ditto/app"], + ["git://github.com/ditto/app.git", "github.com/ditto/app"], + ["https://user:token@github.com/ditto/app.git", "github.com/ditto/app"], + ["https://x-access-token:ghs_abc@github.com/ditto/app", "github.com/ditto/app"], + ["git@gitlab.com:group/subgroup/app.git", "gitlab.com/group/subgroup/app"], + ["https://gitlab.com/group/sub/deeper/app.git", "gitlab.com/group/sub/deeper/app"], + ["/Users/laura/Desktop/Ditto/cli", null], + ["file:///Users/laura/Desktop/Ditto/cli", null], + ["https://github.com/app", null], + ["", null], + ]; + + test.each(cases)("%s", (remoteUrl, expected) => { + expect(normalizeRepoKey(remoteUrl)).toBe(expected); + }); + + test("never leaks credentials", () => { + const key = normalizeRepoKey("https://user:s3cret@github.com/ditto/app.git"); + expect(key).not.toMatch(/s3cret|user|@/); + }); + + test("every non-null key satisfies REPO_KEY_PATTERN", () => { + for (const [remoteUrl] of cases) { + const key = normalizeRepoKey(remoteUrl); + if (key !== null) expect(key).toMatch(REPO_KEY_PATTERN); + } + }); +}); + +describe("readGitContext", () => { + test("reads this repo", async () => { + const context = await readGitContext(__dirname); + expect(context).not.toBeNull(); + expect(context!.repoKey).toBe("github.com/dittowords/cli"); + expect(context!.commitSha).toMatch(/^[0-9a-f]{40}$/); + expect(context!.repoRoot).toBe( + fs.realpathSync(path.resolve(__dirname, "../../..")) + ); + }); + + test("resolves null outside a repo", async () => { + await expect(readGitContext(fs.realpathSync(os.tmpdir()))).resolves.toBeNull(); + }); + + describe("temp repo", () => { + let dir: string; + + beforeAll(() => { + dir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "git-ctx-")); + const run = (...args: string[]) => + execFileSync("git", args, { cwd: dir, stdio: "ignore" }); + run("init", "-b", "main"); + run("config", "user.email", "test@example.com"); + run("config", "user.name", "Test"); + fs.writeFileSync(path.join(dir, "a.txt"), "hello\n"); + run("add", "."); + run("commit", "-m", "init"); + }); + + afterAll(() => fs.rmSync(dir, { recursive: true, force: true })); + + test("no remote resolves null", async () => { + await expect(readGitContext(dir)).resolves.toBeNull(); + }); + + test("branch, sha and clean tree", async () => { + execFileSync("git", ["remote", "add", "origin", "git@github.com:Ditto/App.git"], { + cwd: dir, + stdio: "ignore", + }); + const context = await readGitContext(dir); + expect(context).toEqual({ + repoKey: "github.com/ditto/app", + repoRoot: dir, + commitSha: expect.stringMatching(/^[0-9a-f]{40}$/), + branch: "main", + dirty: false, + }); + }); + + test("dirty tree", async () => { + fs.writeFileSync(path.join(dir, "a.txt"), "changed\n"); + const context = await readGitContext(dir); + expect(context!.dirty).toBe(true); + execFileSync("git", ["checkout", "--", "a.txt"], { cwd: dir, stdio: "ignore" }); + }); + + test("detached HEAD gives a null branch and a sha", async () => { + const sha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: dir }) + .toString() + .trim(); + execFileSync("git", ["checkout", "--detach", sha], { cwd: dir, stdio: "ignore" }); + const context = await readGitContext(dir); + expect(context!.branch).toBeNull(); + expect(context!.commitSha).toBe(sha); + execFileSync("git", ["checkout", "main"], { cwd: dir, stdio: "ignore" }); + }); + }); +}); diff --git a/lib/src/scan/git.ts b/lib/src/scan/git.ts new file mode 100644 index 0000000..867f569 --- /dev/null +++ b/lib/src/scan/git.ts @@ -0,0 +1,101 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export interface GitContext { + repoKey: string; + repoRoot: string; + commitSha: string; + branch: string | null; + dirty: boolean; +} + +const REPO_KEY_PATTERN = + /^[a-z0-9.-]+(\/[a-z0-9._-]+){1,}\/(?!.*\.git$)[a-z0-9._-]+$/; + +const SCP_LIKE = /^(?:[^/@]+@)?([^/:]+):(.+)$/; + +/** + * Reduces a remote URL to a repo key: both `git@github.com:Ditto/App.git` and + * `https://github.com/ditto/app` give `github.com/ditto/app`. Parsing drops + * any credentials and port; nested subgroup paths survive. + * + * @returns The key, or `null` if it doesn't parse or fails `REPO_KEY_PATTERN`. + */ +export function normalizeRepoKey(remoteUrl: string): string | null { + const trimmed = remoteUrl.trim(); + if (!trimmed) return null; + + let host: string; + let pathname: string; + + if (trimmed.includes("://")) { + let url: URL; + try { + url = new URL(trimmed); + } catch { + return null; + } + host = url.hostname; + pathname = url.pathname; + } else { + const scp = SCP_LIKE.exec(trimmed); + if (!scp) return null; + host = scp[1]; + pathname = scp[2]; + } + + const segments = pathname.split("/").filter(Boolean); + if (segments.length === 0) return null; + segments[segments.length - 1] = segments[segments.length - 1].replace( + /\.git$/, + "" + ); + if (!segments[segments.length - 1]) return null; + + const key = [host, ...segments].join("/").toLowerCase(); + return REPO_KEY_PATTERN.test(key) ? key : null; +} + +/** Trimmed stdout, or `null` if git is missing or exits non-zero. */ +async function git(args: string[], cwd: string): Promise { + try { + const { stdout } = await execFileAsync("git", args, { cwd }); + return stdout.trim(); + } catch { + return null; + } +} + +/** + * Reads the git context a scan uploads with its candidates, discovering the + * repo from `startDir`. `branch` is `null` on a detached HEAD. + * + * @returns `null` — never throws — with no git, repo, commit, or usable + * `origin`. + */ +export async function readGitContext( + startDir: string +): Promise { + const repoRoot = await git(["rev-parse", "--show-toplevel"], startDir); + if (!repoRoot) return null; + + const commitSha = await git(["rev-parse", "HEAD"], repoRoot); + if (!commitSha) return null; + + const remoteUrl = await git(["remote", "get-url", "origin"], repoRoot); + const repoKey = remoteUrl ? normalizeRepoKey(remoteUrl) : null; + if (!repoKey) return null; + + const branch = await git(["symbolic-ref", "--quiet", "--short", "HEAD"], repoRoot); + const status = await git(["status", "--porcelain"], repoRoot); + + return { + repoKey, + repoRoot, + commitSha, + branch: branch || null, + dirty: Boolean(status), + }; +} From 94b06c7524e51a94739b01a196edc758004dcf65 Mon Sep 17 00:00:00 2001 From: Laura Koye Date: Wed, 19 Aug 2026 13:33:18 -0500 Subject: [PATCH 2/7] bumping --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index da38a10..7acfedb 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "@babel/parser": "^7.21.4", "@babel/traverse": "^7.21.4", "@babel/types": "^7.21.4", - "@dittowords/text-extract": "^0.2.1", + "@dittowords/text-extract": "^0.3.0", "@sentry/node": "^7.64.0", "@types/babel-traverse": "^6.25.7", "axios": "^1.6.0", diff --git a/yarn.lock b/yarn.lock index 3d813d8..54e8692 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1159,10 +1159,10 @@ dependencies: "@jridgewell/trace-mapping" "0.3.9" -"@dittowords/text-extract@^0.2.1": - version "0.2.1" - resolved "https://registry.yarnpkg.com/@dittowords/text-extract/-/text-extract-0.2.1.tgz#0db692cb2f8112b2f9e5aca00eb308bdcd478cc9" - integrity sha512-Dq/N+sNQo5sCLmrsVnTbEf5a0NxT9JpPsVsbuMLOhO7BxZJEaoOpnvQQAJRnV8bgp+lWMTQgbMshBJVIMh0U+w== +"@dittowords/text-extract@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@dittowords/text-extract/-/text-extract-0.3.0.tgz#6fcd5b0c7837fb592e0327c0373f2c1c48209d0e" + integrity sha512-iwHvMooJN6J+Oha3vemYNGWaceslWdwJJQbbH5+gqTLJYYSznEZzA3R3gOeMp84p4YuAxYViGkudNsmscDAsPg== dependencies: "@ast-grep/lang-kotlin" "^0.0.7" "@ast-grep/lang-swift" "^0.0.8" From 5a302d5a0e420e2473bc4e3f08d26ec310295a2e Mon Sep 17 00:00:00 2001 From: Laura Koye Date: Wed, 19 Aug 2026 13:38:47 -0500 Subject: [PATCH 3/7] comments --- lib/src/scan/git.test.ts | 5 +---- lib/src/scan/git.ts | 22 ++++++++++++++++++++-- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/lib/src/scan/git.test.ts b/lib/src/scan/git.test.ts index 9bded3e..963e3a9 100644 --- a/lib/src/scan/git.test.ts +++ b/lib/src/scan/git.test.ts @@ -3,10 +3,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { normalizeRepoKey, readGitContext } from "./git"; - -const REPO_KEY_PATTERN = - /^[a-z0-9.-]+(\/[a-z0-9._-]+){1,}\/(?!.*\.git$)[a-z0-9._-]+$/; +import { normalizeRepoKey, readGitContext, REPO_KEY_PATTERN } from "./git"; describe("normalizeRepoKey", () => { const cases: [string, string | null][] = [ diff --git a/lib/src/scan/git.ts b/lib/src/scan/git.ts index 867f569..33131a9 100644 --- a/lib/src/scan/git.ts +++ b/lib/src/scan/git.ts @@ -3,17 +3,32 @@ import { promisify } from "node:util"; const execFileAsync = promisify(execFile); +/** + * Where a scan came from. `repoKey` and `commitSha` identify the same commit + * from any clone; `repoRoot` is local to this machine and isn't uploaded. + */ export interface GitContext { + /** Host and path of `origin`, normalized: `github.com/dittowords/cli`. */ repoKey: string; + /** Absolute path to the working tree. Local only — used to make candidate paths repo-root-relative. */ repoRoot: string; commitSha: string; + /** `null` when HEAD is detached, as it is after `actions/checkout`. */ branch: string | null; + /** Uncommitted changes are present, so `commitSha` doesn't fully describe what was scanned. */ dirty: boolean; } -const REPO_KEY_PATTERN = +/** + * Copied from `shared/types/ProductTextDetection.ts` in `ditto-app` + */ +export const REPO_KEY_PATTERN = /^[a-z0-9.-]+(\/[a-z0-9._-]+){1,}\/(?!.*\.git$)[a-z0-9._-]+$/; +/** + * The scp-like remote form `git@github.com:Ditto/App.git`, which has no scheme + * and so isn't a URL. Captures the host and the path around the colon. + */ const SCP_LIKE = /^(?:[^/@]+@)?([^/:]+):(.+)$/; /** @@ -88,7 +103,10 @@ export async function readGitContext( const repoKey = remoteUrl ? normalizeRepoKey(remoteUrl) : null; if (!repoKey) return null; - const branch = await git(["symbolic-ref", "--quiet", "--short", "HEAD"], repoRoot); + const branch = await git( + ["symbolic-ref", "--quiet", "--short", "HEAD"], + repoRoot + ); const status = await git(["status", "--porcelain"], repoRoot); return { From 0c49e030427c8c1aeb9b6050ed66e165d79f9b03 Mon Sep 17 00:00:00 2001 From: Laura Koye Date: Wed, 19 Aug 2026 13:51:24 -0500 Subject: [PATCH 4/7] Added -c commit.gpgsign=false to the temp repo's git calls so the tests don't break for teammates who sign commits by default + prettier --- lib/src/scan/git.test.ts | 45 ++++++++++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/lib/src/scan/git.test.ts b/lib/src/scan/git.test.ts index 963e3a9..f8f84b4 100644 --- a/lib/src/scan/git.test.ts +++ b/lib/src/scan/git.test.ts @@ -13,9 +13,15 @@ describe("normalizeRepoKey", () => { ["ssh://git@github.com:2222/ditto/app.git", "github.com/ditto/app"], ["git://github.com/ditto/app.git", "github.com/ditto/app"], ["https://user:token@github.com/ditto/app.git", "github.com/ditto/app"], - ["https://x-access-token:ghs_abc@github.com/ditto/app", "github.com/ditto/app"], + [ + "https://x-access-token:ghs_abc@github.com/ditto/app", + "github.com/ditto/app", + ], ["git@gitlab.com:group/subgroup/app.git", "gitlab.com/group/subgroup/app"], - ["https://gitlab.com/group/sub/deeper/app.git", "gitlab.com/group/sub/deeper/app"], + [ + "https://gitlab.com/group/sub/deeper/app.git", + "gitlab.com/group/sub/deeper/app", + ], ["/Users/laura/Desktop/Ditto/cli", null], ["file:///Users/laura/Desktop/Ditto/cli", null], ["https://github.com/app", null], @@ -27,7 +33,9 @@ describe("normalizeRepoKey", () => { }); test("never leaks credentials", () => { - const key = normalizeRepoKey("https://user:s3cret@github.com/ditto/app.git"); + const key = normalizeRepoKey( + "https://user:s3cret@github.com/ditto/app.git" + ); expect(key).not.toMatch(/s3cret|user|@/); }); @@ -51,7 +59,9 @@ describe("readGitContext", () => { }); test("resolves null outside a repo", async () => { - await expect(readGitContext(fs.realpathSync(os.tmpdir()))).resolves.toBeNull(); + await expect( + readGitContext(fs.realpathSync(os.tmpdir())) + ).resolves.toBeNull(); }); describe("temp repo", () => { @@ -60,7 +70,10 @@ describe("readGitContext", () => { beforeAll(() => { dir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "git-ctx-")); const run = (...args: string[]) => - execFileSync("git", args, { cwd: dir, stdio: "ignore" }); + execFileSync("git", ["-c", "commit.gpgsign=false", ...args], { + cwd: dir, + stdio: "ignore", + }); run("init", "-b", "main"); run("config", "user.email", "test@example.com"); run("config", "user.name", "Test"); @@ -76,10 +89,14 @@ describe("readGitContext", () => { }); test("branch, sha and clean tree", async () => { - execFileSync("git", ["remote", "add", "origin", "git@github.com:Ditto/App.git"], { - cwd: dir, - stdio: "ignore", - }); + execFileSync( + "git", + ["remote", "add", "origin", "git@github.com:Ditto/App.git"], + { + cwd: dir, + stdio: "ignore", + } + ); const context = await readGitContext(dir); expect(context).toEqual({ repoKey: "github.com/ditto/app", @@ -94,14 +111,20 @@ describe("readGitContext", () => { fs.writeFileSync(path.join(dir, "a.txt"), "changed\n"); const context = await readGitContext(dir); expect(context!.dirty).toBe(true); - execFileSync("git", ["checkout", "--", "a.txt"], { cwd: dir, stdio: "ignore" }); + execFileSync("git", ["checkout", "--", "a.txt"], { + cwd: dir, + stdio: "ignore", + }); }); test("detached HEAD gives a null branch and a sha", async () => { const sha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: dir }) .toString() .trim(); - execFileSync("git", ["checkout", "--detach", sha], { cwd: dir, stdio: "ignore" }); + execFileSync("git", ["checkout", "--detach", sha], { + cwd: dir, + stdio: "ignore", + }); const context = await readGitContext(dir); expect(context!.branch).toBeNull(); expect(context!.commitSha).toBe(sha); From 5f15da0c85c4cb40478be0c991855b37b634859f Mon Sep 17 00:00:00 2001 From: Laura Koye Date: Wed, 19 Aug 2026 13:56:03 -0500 Subject: [PATCH 5/7] comments making test file easier to understand --- lib/src/scan/git.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/src/scan/git.test.ts b/lib/src/scan/git.test.ts index f8f84b4..4a06735 100644 --- a/lib/src/scan/git.test.ts +++ b/lib/src/scan/git.test.ts @@ -3,6 +3,10 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +/** + * Real git repos, not mocks: this checkout, plus a throwaway repo in the temp + * directory for the awkward states. Needs `git` on PATH and a real `.git`. + */ import { normalizeRepoKey, readGitContext, REPO_KEY_PATTERN } from "./git"; describe("normalizeRepoKey", () => { @@ -39,6 +43,7 @@ describe("normalizeRepoKey", () => { expect(key).not.toMatch(/s3cret|user|@/); }); + /** Cannot fail today - `normalizeRepoKey` already applies the pattern. */ test("every non-null key satisfies REPO_KEY_PATTERN", () => { for (const [remoteUrl] of cases) { const key = normalizeRepoKey(remoteUrl); @@ -48,6 +53,10 @@ describe("normalizeRepoKey", () => { }); describe("readGitContext", () => { + /** + * Skips `branch` and `dirty` on purpose: CI checks out a detached HEAD, and + * local runs usually have uncommitted work. + */ test("reads this repo", async () => { const context = await readGitContext(__dirname); expect(context).not.toBeNull(); @@ -64,6 +73,11 @@ describe("readGitContext", () => { ).resolves.toBeNull(); }); + /** + * One repo walked through each state - no remote, remote, edited file, + * detached HEAD - restored after each test. Sets its own identity and + * disables signing so it ignores the local global git config. + */ describe("temp repo", () => { let dir: string; From 3b5374710bc4b9280144690517fceac719388a48 Mon Sep 17 00:00:00 2001 From: Laura Koye Date: Thu, 20 Aug 2026 11:23:38 -0500 Subject: [PATCH 6/7] bumping package and adding port test --- lib/src/scan/git.test.ts | 1 + package.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/src/scan/git.test.ts b/lib/src/scan/git.test.ts index 4a06735..0c23083 100644 --- a/lib/src/scan/git.test.ts +++ b/lib/src/scan/git.test.ts @@ -15,6 +15,7 @@ describe("normalizeRepoKey", () => { ["https://github.com/ditto/app", "github.com/ditto/app"], ["https://github.com/ditto/app.git", "github.com/ditto/app"], ["ssh://git@github.com:2222/ditto/app.git", "github.com/ditto/app"], + ["git@github.com:2222/ditto/app.git", "github.com/2222/ditto/app"], ["git://github.com/ditto/app.git", "github.com/ditto/app"], ["https://user:token@github.com/ditto/app.git", "github.com/ditto/app"], [ diff --git a/package.json b/package.json index 7acfedb..902bf21 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dittowords/cli", - "version": "5.7.2", + "version": "5.8.0", "description": "Command Line Interface for Ditto (dittowords.com).", "license": "MIT", "main": "bin/ditto.js", From 07701e70c869221dd22b79aab655293157ac8871 Mon Sep 17 00:00:00 2001 From: Laura Koye Date: Thu, 20 Aug 2026 13:22:35 -0500 Subject: [PATCH 7/7] [DIT-13467]: send git metadata with post v2scan (#155) * [DIT-13467]: Send git metadata with POST /v2/scan * scanning a directory with no extractable strings now actually prints that warning * repoRelativeRoot --- lib/src/commands/scan.ts | 37 ++++++++++++++++----- lib/src/http/scan.test.ts | 70 +++++++++++++++++++++++++++++++++++++++ lib/src/http/scan.ts | 56 +++++++++++++++++++++++++++---- lib/src/http/types.ts | 5 +++ 4 files changed, 154 insertions(+), 14 deletions(-) create mode 100644 lib/src/http/scan.test.ts diff --git a/lib/src/commands/scan.ts b/lib/src/commands/scan.ts index a8dd935..1a53cb3 100644 --- a/lib/src/commands/scan.ts +++ b/lib/src/commands/scan.ts @@ -4,15 +4,12 @@ import path from "path"; import { prompt } from "enquirer"; import open from "open"; -import logger from "../utils/logger"; import { DittoScanCandidate, DittoScanExtractSummary, runExtract, } from "@dittowords/text-extract"; -import { quit } from "../utils/quit"; -import initAPIToken from "../services/apiToken/initAPIToken"; -import appContext from "../utils/appContext"; +import chalk from "chalk"; import { asScanLimitInfo, initiateClassify, @@ -25,8 +22,12 @@ import { formatDirectoryBreakdown, formatOverLimitMessage, } from "../scan/analyzeDirectories"; +import { readGitContext } from "../scan/git"; +import initAPIToken from "../services/apiToken/initAPIToken"; +import appContext from "../utils/appContext"; import DittoError, { ErrorType } from "../utils/DittoError"; -import chalk from "chalk"; +import logger from "../utils/logger"; +import { quit } from "../utils/quit"; // Yarn sets INIT_CWD to the directory the user invoked yarn from, which // matters when we proxy via `cd product-text-detection && yarn ptd`. @@ -161,8 +162,10 @@ export const scan = async ( }); if (candidates.length === 0) { - logger.warnText( - `[ditto scan] no candidates extracted; writing empty classify output\n` + logger.writeLine( + logger.warnText( + `[ditto scan] no candidates extracted; writing empty classify output\n` + ) ); } @@ -188,13 +191,31 @@ export const scan = async ( await writeCandidatesNdjson(candidates, candidatesPath); logExtractSummary(extractSummary, candidatesPath); } else { + const gitContext = await readGitContext(resolvedInput); + if (!gitContext) { + logger.writeLine( + logger.warnText( + "[ditto scan] not a git repository - this scan can be imported but not re-synced\n" + ) + ); + } else if (gitContext.dirty) { + logger.writeLine( + logger.warnText( + `[ditto scan] uncommitted changes present; recording ${gitContext.commitSha.slice( + 0, + 7 + )} as an approximate commit\n` + ) + ); + } + const token = await initAPIToken(); appContext.setAuthToken(token); const { candidatesSignedS3Url, record: { _id: recordId }, planLimit, - } = await initiateScan(resolvedInput); + } = await initiateScan(resolvedInput, gitContext); // Fail before the wasted upload when the candidates we already extracted exceed it. if ( diff --git a/lib/src/http/scan.test.ts b/lib/src/http/scan.test.ts new file mode 100644 index 0000000..c084f80 --- /dev/null +++ b/lib/src/http/scan.test.ts @@ -0,0 +1,70 @@ +import { GitContext } from "../scan/git"; +import { buildInitiateScanBody } from "./scan"; +import { ZInitiateScanBodySchema } from "./types"; + +const context: GitContext = { + repoKey: "github.com/dittowords/cli", + repoRoot: "/Users/laura/cli", + commitSha: "d3c1a8148580e1869c91ee6caadda17165ceb0ea", + branch: "master", + dirty: false, +}; + +describe("buildInitiateScanBody", () => { + test("sends only path when there is no git context", () => { + expect(buildInitiateScanBody("/repo", null)).toEqual({ path: "/repo" }); + }); + + test("sends repo, sha, branch and repo-relative root when there is", () => { + expect(buildInitiateScanBody("/Users/laura/cli/lib/src", context)).toEqual({ + path: "/Users/laura/cli/lib/src", + repoKey: "github.com/dittowords/cli", + gitCommitSha: "d3c1a8148580e1869c91ee6caadda17165ceb0ea", + gitBranch: "master", + repoRelativeRoot: "lib/src", + }); + }); + + test("sends an empty repo-relative root when scanning the repo root", () => { + const body = buildInitiateScanBody("/Users/laura/cli", context); + expect(body.repoRelativeRoot).toBe(""); + }); + + test("omits the repo-relative root when the path is outside the repo", () => { + const body = buildInitiateScanBody("/elsewhere/src", context); + expect(body).not.toHaveProperty("repoRelativeRoot"); + }); + + test("sends a null branch on a detached HEAD", () => { + const body = buildInitiateScanBody("/Users/laura/cli", { + ...context, + branch: null, + }); + expect(body.gitBranch).toBeNull(); + expect(body.gitCommitSha).toBe(context.commitSha); + }); + + test("never sends repoRoot or dirty", () => { + const body = buildInitiateScanBody("/Users/laura/cli/lib", { + ...context, + dirty: true, + }); + expect(Object.keys(body).sort()).toEqual([ + "gitBranch", + "gitCommitSha", + "path", + "repoKey", + "repoRelativeRoot", + ]); + }); + + test("every body satisfies the request schema", () => { + for (const c of [null, context, { ...context, branch: null }]) { + expect(() => + ZInitiateScanBodySchema.parse( + buildInitiateScanBody("/Users/laura/cli/lib", c) + ) + ).not.toThrow(); + } + }); +}); diff --git a/lib/src/http/scan.ts b/lib/src/http/scan.ts index 87338d6..e0a2557 100644 --- a/lib/src/http/scan.ts +++ b/lib/src/http/scan.ts @@ -1,9 +1,15 @@ -import axios, { AxiosError } from "axios"; -import getHttpClient from "./client"; -import { IInitiateScanResponse, ZInitiateScanResponse } from "./types"; import { DittoScanCandidate } from "@dittowords/text-extract"; -import DittoError, { ErrorType } from "../utils/DittoError"; +import axios, { AxiosError } from "axios"; import { Blob } from "buffer"; +import { relative, sep } from "node:path"; +import { GitContext } from "../scan/git"; +import DittoError, { ErrorType } from "../utils/DittoError"; +import getHttpClient from "./client"; +import { + IInitiateScanBody, + IInitiateScanResponse, + ZInitiateScanResponse, +} from "./types"; // Structured details from the classify step's SCAN_CANDIDATE_LIMIT_EXCEEDED // response. @@ -68,12 +74,50 @@ export function scanLimitError( }); } +/** + * Where the scanned directory sits inside the repo, as a forward-slash path the + * server prefixes onto candidate paths to build code links (`toRepoRelativePath` + * in ditto-app `services/ai/productTextDetection/codeLinks.ts`). `""` when the + * scan is the repo root, and `undefined` when the scanned path is somehow + * outside the repo, so a bad value never becomes a wrong link. + */ +function repoRelativeRoot( + scannedPath: string, + repoRoot: string +): string | undefined { + const rel = relative(repoRoot, scannedPath); + if (rel.startsWith("..")) return undefined; + return sep === "/" ? rel : rel.split(sep).join("/"); +} + +/** + * Builds the `POST /v2/scan` body. Without git context the body is exactly what + * the CLI has always sent, so a scan outside a repo is unaffected. + */ +export function buildInitiateScanBody( + path: string, + gitContext?: GitContext | null +): IInitiateScanBody { + if (!gitContext) return { path }; + const root = repoRelativeRoot(path, gitContext.repoRoot); + return { + path, + repoKey: gitContext.repoKey, + gitCommitSha: gitContext.commitSha, + gitBranch: gitContext.branch, + ...(root === undefined ? {} : { repoRelativeRoot: root }), + }; +} + export async function initiateScan( - path: string + path: string, + gitContext?: GitContext | null ): Promise { + const body = buildInitiateScanBody(path, gitContext); + try { const httpClient = getHttpClient({}); - const response = await httpClient.post("/v2/scan", { path }); + const response = await httpClient.post("/v2/scan", body); return ZInitiateScanResponse.parse(response.data); } catch (e) { if (!(e instanceof AxiosError)) { diff --git a/lib/src/http/types.ts b/lib/src/http/types.ts index d5171a7..71d802d 100644 --- a/lib/src/http/types.ts +++ b/lib/src/http/types.ts @@ -180,7 +180,12 @@ export type IExportSwiftFileRequest = z.infer; export const ZInitiateScanBodySchema = z.object({ path: z.string(), + repoKey: z.string().optional(), + gitCommitSha: z.string().optional(), + gitBranch: z.string().nullable().optional(), + repoRelativeRoot: z.string().optional(), }); +export type IInitiateScanBody = z.infer; export const ZInitiateScanResponse = z.object({ record: z.object({ _id: z.string() }), candidatesSignedS3Url: z.string(),