diff --git a/.github/workflows/javascript.yml b/.github/workflows/javascript.yml index 330cd3e..b8640a9 100644 --- a/.github/workflows/javascript.yml +++ b/.github/workflows/javascript.yml @@ -11,9 +11,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: Use Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v6 + with: + node-version-file: ".nvmrc" - name: Install dependencies run: yarn install --frozen-lockfile - name: Run tests @@ -22,9 +24,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: Use Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v6 + with: + node-version-file: ".nvmrc" - name: Install dependencies run: yarn install --frozen-lockfile - name: Run tests diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..85e5027 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.22.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bfff59..5d5600a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## 4.0.0 - 2026-03-16 + +### Changed + +- Extension now calls `git` instead of manually reading `.git/` files. (#75) + ## 3.4.0 - 2026-03-07 ### Changed diff --git a/images/logo.sketch b/images/logo.sketch index 1049889..19f7bef 100644 Binary files a/images/logo.sketch and b/images/logo.sketch differ diff --git a/package.json b/package.json index 502a795..50827f8 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "githubinator", "displayName": "Githubinator", "description": "Quickly open files on Github and other providers. View blame information, copy permalinks and more. See the \"commands\" section of the README for more details.", - "version": "3.4.0", + "version": "4.0.0", "publisher": "chdsbd", "license": "SEE LICENSE IN LICENSE", "icon": "images/logo256.png", @@ -455,14 +455,14 @@ "@types/lodash": "^4.14.170", "@types/mocha": "^10.0.6", "@types/mz": "^2.7.3", - "@types/node": "^15.12.4", - "@types/vscode": "^1.32.0", + "@types/node": "^22.19.15", + "@types/vscode": "^1.110.0", "@vscode/test-cli": "^0.0.9", "@vscode/test-electron": "^2.4.0", "esbuild": "^0.11.12", "mocha": "^10.4.0", "prettier": "^3.3.0", - "typescript": "^4.2.2", + "typescript": "^5.9.3", "vscode-test": "^1.6.1" }, "dependencies": { @@ -472,9 +472,8 @@ "lodash": "^4.17.21", "mz": "^2.7.0" }, - "packageManager": "yarn@1.22.22", - "volta": { - "node": "18.20.3", - "yarn": "1.22.22" - } + "extensionDependencies": [ + "vscode.git" + ], + "packageManager": "yarn@1.22.22" } diff --git a/src/extension.ts b/src/extension.ts index 993a341..45216e0 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -77,28 +77,35 @@ export interface IGithubinatorConfig { } } +export let outputChannel: vscode.LogOutputChannel + export function activate(context: vscode.ExtensionContext) { - console.log("githubinator.active.start") + outputChannel = vscode.window.createOutputChannel("GitHubinator", { + log: true, + }) + outputChannel.debug("githubinator.active.start") + context.subscriptions.push(outputChannel) COMMANDS.forEach(([cmd, args]) => { const disposable = vscode.commands.registerCommand(cmd, () => githubinator(args), ) context.subscriptions.push(disposable) }) - vscode.commands.registerCommand( + const openFromUrlDisposable = vscode.commands.registerCommand( "githubinator.githubinatorOpenFromUrl", openFileFromGitHubUrl, ) + context.subscriptions.push(openFromUrlDisposable) - console.log("githubinator.active.complete") + outputChannel.debug("githubinator.active.complete") } export function deactivate() { - console.log("githubinator.deactivate") + outputChannel.debug("githubinator.deactivate") } function err(message: string) { - console.error(message) + outputChannel.error(message) vscode.window.showErrorMessage(message) } @@ -127,11 +134,15 @@ function mainBranches() { .get("mainBranches", ["main"]) } +/** + * Search default main branch names for the first one that exists. + */ async function findShaForBranches( - gitDir: string, + gitRepository: git.Repo, + fileUri: vscode.Uri, ): Promise<[string, string] | null> { for (let branch of mainBranches()) { - const sha = await git.getSHAForBranch(gitDir, branch) + const sha = await git.getSHAForBranch(gitRepository, branch) if (sha == null) { continue } @@ -151,41 +162,41 @@ interface IGithubinator { openPR?: boolean compare?: boolean } -async function githubinator({ - openUrl, - copyToClipboard, - blame, - mainBranch, - openRepo, - permalink, - history, - openPR, - compare, -}: IGithubinator) { - console.log("githubinator.call") +async function githubinator(options: IGithubinator) { + const { + openUrl, + copyToClipboard, + blame, + mainBranch, + openRepo, + permalink, + history, + openPR, + compare, + } = options + outputChannel.info( + "githubinator called with options: " + JSON.stringify(options), + ) const editorConfig = getEditorInfo() if (!editorConfig.uri) { - return err("could not find file") + return err("Could not find file for current editor.") } + const fileUri = editorConfig.uri - const gitDirectories = git.dir(editorConfig.uri.fsPath) - - if (gitDirectories == null) { - return err("Could not find .git directory.") + const gitRepository = await git.getRepo(fileUri) + if (!gitRepository) { + return err("Could not find git repository for file.") } - const gitDir = gitDirectories.git - const repoDir = gitDirectories.repository - let headBranch: [string, string | null] | null = null if (mainBranch) { - const res = await findShaForBranches(gitDir) + const res = await findShaForBranches(gitRepository, fileUri) if (res == null) { return err(`Could not find SHA for branch in ${mainBranches()}`) } headBranch = res } else { - headBranch = await git.head(gitDir) + headBranch = await git.head(gitRepository) } if (headBranch == null) { return err("Could not find HEAD.") @@ -209,7 +220,7 @@ async function githubinator({ const parsedUrl = await new provider( providersConfig, globalDefaultRemote, - (remote) => git.origin(gitDir, remote), + (remote) => git.origin(gitRepository, remote), ).getUrls({ selection, // priority: permalink > branch > branch from HEAD @@ -220,19 +231,19 @@ async function githubinator({ ? createSha(head) : createBranch(branchName), relativeFilePath: editorConfig.fileName - ? getRelativeFilePath(repoDir, editorConfig.fileName) + ? getRelativeFilePath(gitRepository.rootUri, editorConfig.fileName) : null, }) if (parsedUrl != null) { - console.log("Found provider", provider.name) + outputChannel.debug("Found provider", provider.name) urls = parsedUrl break } - console.log("Skipping provider", provider.name) + outputChannel.debug("Skipping provider", provider.name) } if (urls == null) { - return err("Could not find provider for repo.") + return err("Could not find remote for repository.") } let url = compare diff --git a/src/git.ts b/src/git.ts index 4df9866..3369c12 100644 --- a/src/git.ts +++ b/src/git.ts @@ -1,139 +1,75 @@ -import * as path from "path" -import * as fs from "mz/fs" -import * as ini from "ini" +import * as vscode from "vscode" +import { execFile } from "node:child_process" +import { promisify } from "node:util" +import * as path from "node:path" +import { outputChannel } from "./extension" -interface IRemote { - fetch: string - url?: string +export interface Repo { + rootUri: vscode.Uri } -interface IGitDirectories { - git: string - repository: string +const execFileAsync = promisify(execFile) + +async function git(cwd: string, ...args: string[]): Promise { + const { stdout } = await execFileAsync("git", args, { cwd }) + return stdout.trim() +} + +export async function getRepo(fileUri: vscode.Uri): Promise { + try { + const stat = await vscode.workspace.fs.stat(fileUri) + const cwd = + stat.type & vscode.FileType.Directory + ? fileUri.fsPath + : path.dirname(fileUri.fsPath) + const root = await git(cwd, "rev-parse", "--show-toplevel") + return { rootUri: vscode.Uri.file(root) } + } catch { + outputChannel.warn("Could not find git repository for file", fileUri.fsPath) + return null + } } export async function origin( - gitDir: string, + repo: Repo, remote: string, ): Promise { - const configPath = path.resolve(gitDir, "config") - if (!(await fs.exists(configPath))) { + try { + return await git(repo.rootUri.fsPath, "remote", "get-url", remote) + } catch { return null } - const configFileData = await fs.readFile(configPath, { encoding: "utf-8" }) - const parsedConfig = ini.parse(configFileData) - for (const [key, value] of Object.entries(parsedConfig)) { - if (key.startsWith('remote "')) { - const origin = key.replace(/^remote "/, "").replace(/"$/, "") - if (origin === remote) { - const url = (value as IRemote).url - return url || null - } - } - } - - return null } -/** Get the SHA for a ref */ export async function getSHAForBranch( - gitDir: string, + repo: Repo, branchName: string, ): Promise { - const refName = `refs/heads/${branchName}` - // check for normal ref - const refPath = path.resolve(gitDir, refName) - if (await fs.exists(refPath)) { - return await fs.readFile(refPath, { - encoding: "utf-8", - }) - } - // check packed-refs - const packedRefPath = path.resolve(gitDir, "packed-refs") - if (await fs.exists(packedRefPath)) { - const packRefs = await fs.readFile(packedRefPath, { - encoding: "utf-8", - }) - - for (const x of packRefs.split("\n")) { - const [sha, refPath] = x.split(" ") as [ - string | undefined, - string | undefined, - ] - if (sha && refPath && refPath.trim() === refName.trim()) { - return sha - } - } + try { + return await git(repo.rootUri.fsPath, "rev-parse", branchName) + } catch { + return null } - return null } -/** Get the current SHA and branch from HEAD for a git directory */ export async function head( - gitDir: string, + repo: Repo, ): Promise<[string, string | null] | null> { - const headPath = path.resolve(gitDir, "HEAD") - if (!(await fs.exists(headPath))) { - return null - } - const headFileData = await fs.readFile(headPath, { encoding: "utf-8" }) - if (!headFileData) { - return null - } - // If we're not on a branch, headFileData will be of the form: - // `3c0cc80bbdb682f6e9f65b4c9659ca21924aad4` - // If we're on a branch, it will be `ref: refs/heads/my_branch_name` - const [maybeSha, maybeHeadInfo] = headFileData.split(" ") as [ - string, - string | undefined, - ] - if (maybeHeadInfo == null) { - return [maybeSha.trim(), null] - } - const branchName = maybeHeadInfo.trim().replace("refs/heads/", "") - const sha = await getSHAForBranch(gitDir, branchName) - if (sha == null) { - return null - } - return [sha.trim(), branchName] -} - -export function dir(filePath: string) { - return walkUpDirectories(filePath, ".git") -} - -function walkUpDirectories( - file_path: string, - file_or_folder: string, -): IGitDirectories | null { - let directory = file_path - while (true) { - const newPath = path.resolve(directory, file_or_folder) - if (fs.existsSync(newPath)) { - if (fs.lstatSync(newPath).isFile()) { - const submoduleMatch = fs - .readFileSync(newPath, "utf8") - .match(/gitdir: (.+)/) - - if (submoduleMatch) { - return { - git: path.resolve(path.join(directory, submoduleMatch[1])), - repository: directory, - } - } else { - return null - } - } else { - return { - git: newPath, - repository: directory, - } - } - } - const newDirectory = path.dirname(directory) - if (newDirectory === directory) { - return null + try { + const sha = await git(repo.rootUri.fsPath, "rev-parse", "HEAD") + let branchName: string | null = null + try { + branchName = await git( + repo.rootUri.fsPath, + "symbolic-ref", + "--short", + "HEAD", + ) + } catch { + // detached HEAD, branchName stays null } - directory = newDirectory + return [sha, branchName] + } catch { + return null } } diff --git a/src/test/suite/git.test.ts b/src/test/suite/git.test.ts deleted file mode 100644 index 398d750..0000000 --- a/src/test/suite/git.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { dir } from "../../git" -import * as assert from "assert" -import * as path from "path" -import * as fs from "fs" - -suite("git", async () => { - test("dir", () => { - const repoPath = path.normalize(path.join(__dirname, "../../..")) - const gitPath = path.join(repoPath, ".git") - - assert.deepStrictEqual(dir(__dirname), { - git: gitPath, - repository: repoPath, - }) - assert.deepStrictEqual(dir(repoPath), { - git: gitPath, - repository: repoPath, - }) - - const contents = "gitdir: ../../../../.git/modules/test_submodule" - const submodulePath = path.join(__dirname, "test_submodule") - fs.mkdirSync(submodulePath, { recursive: true }) - fs.writeFileSync(path.join(submodulePath, ".git"), contents) - - assert.deepStrictEqual(dir(submodulePath), { - git: path.join(repoPath, ".git/modules/test_submodule"), - repository: submodulePath, - }) - }) -}) diff --git a/src/test/suite/providers.test.ts b/src/test/suite/providers.test.ts index 8531687..998904b 100644 --- a/src/test/suite/providers.test.ts +++ b/src/test/suite/providers.test.ts @@ -40,7 +40,7 @@ suite("Github", async () => { "git@github.com:recipeyak/recipeyak", "org-XYZ123@github.com:recipeyak/recipeyak", ]) { - async function findRemote(hostname: string) { + const findRemote = async (hostname: string) => { return url } const gh = new Github({}, "origin", findRemote) @@ -75,7 +75,7 @@ suite("Github", async () => { "org-XYZ123@github.mycompany.com:recipeyak/recipeyak", "ssh://git@github.mycompany.com/recipeyak/recipeyak.git", ]) { - async function findRemote(hostname: string) { + const findRemote = async (hostname: string) => { return url } const gh = new Github( @@ -116,7 +116,7 @@ suite("Github", async () => { "org-XYZ123@github.mycompany.com:recipeyak/recipeyak", "ssh://git@github.mycompany.com/recipeyak/recipeyak.git", ]) { - async function findRemote(hostname: string) { + const findRemote = async (hostname: string) => { return url } const gh = new Github( diff --git a/src/utils.ts b/src/utils.ts index ec7a2c5..c29cd52 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,13 +1,14 @@ import * as path from "path" import * as fs from "fs" +import * as vscode from "vscode" /** Get path of file relative to repository root. */ export function getRelativeFilePath( - repositoryDir: string, + repositoryDir: vscode.Uri, fileName: string, ): string | null { try { const resolvedFileName = fs.realpathSync(fileName) - return resolvedFileName.replace(repositoryDir, "") + return resolvedFileName.replace(repositoryDir.fsPath, "") } catch (e) { if ( typeof e === "object" && diff --git a/yarn.lock b/yarn.lock index 3148f77..48ecb00 100644 --- a/yarn.lock +++ b/yarn.lock @@ -84,15 +84,22 @@ dependencies: "@types/node" "*" -"@types/node@*", "@types/node@^15.12.4": +"@types/node@*": version "15.12.4" resolved "https://registry.yarnpkg.com/@types/node/-/node-15.12.4.tgz#e1cf817d70a1e118e81922c4ff6683ce9d422e26" integrity sha512-zrNj1+yqYF4WskCMOHwN+w9iuD12+dGm0rQ35HLl9/Ouuq52cEtd0CH9qMgrdNmi5ejC1/V7vKEXYubB+65DkA== -"@types/vscode@^1.32.0": - version "1.57.0" - resolved "https://registry.yarnpkg.com/@types/vscode/-/vscode-1.57.0.tgz#cc648e0573b92f725cd1baf2621f8da9f8bc689f" - integrity sha512-FeznBFtIDCWRluojTsi9c3LLcCHOXP5etQfBK42+ixo1CoEAchkw39tuui9zomjZuKfUVL33KZUDIwHZ/xvOkQ== +"@types/node@^22.19.15": + version "22.19.15" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.19.15.tgz#6091d99fdf7c08cb57dc8b1345d407ba9a1df576" + integrity sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg== + dependencies: + undici-types "~6.21.0" + +"@types/vscode@^1.110.0": + version "1.110.0" + resolved "https://registry.yarnpkg.com/@types/vscode/-/vscode-1.110.0.tgz#b6210c7d5e049003138bb17311644fe8b179dc8b" + integrity sha512-AGuxUEpU4F4mfuQjxPPaQVyuOMhs+VT/xRok1jiHVBubHK7lBRvCuOMZG0LKUwxncrPorJ5qq/uil3IdZBd5lA== "@vscode/test-cli@^0.0.9": version "0.0.9" @@ -1433,10 +1440,15 @@ to-regex-range@^5.0.1: resolved "https://registry.yarnpkg.com/traverse/-/traverse-0.3.9.tgz#717b8f220cc0bb7b44e40514c22b2e8bbc70d8b9" integrity sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ== -typescript@^4.2.2: - version "4.3.4" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.3.4.tgz#3f85b986945bcf31071decdd96cf8bfa65f9dcbc" - integrity sha512-uauPG7XZn9F/mo+7MrsRjyvbxFpzemRjKEZXS4AK83oP2KKOJPvb+9cO/gmnv8arWZvhnjVOXz7B49m1l0e9Ew== +typescript@^5.9.3: + version "5.9.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" + integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== + +undici-types@~6.21.0: + version "6.21.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb" + integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== unzipper@^0.10.11: version "0.10.14"