├── .gitignore ├── mod.ts ├── deno.jsonc ├── process_test.ts ├── .github └── workflows │ ├── jsr.yml │ ├── test.yml │ └── udd.yml ├── .gitmessage ├── home_url.ts ├── commit_url.ts ├── LICENSE ├── process.ts ├── object_url.ts ├── hosting_service ├── mod.ts ├── services │ ├── bitbucket_org.ts │ ├── github_com.ts │ └── gitlab_com.ts ├── mod_test.ts └── __snapshots__ │ └── mod_test.ts.snap ├── pull_request_url.ts ├── README.md ├── bin ├── browse.ts └── browse_usage.txt ├── util.ts └── util_test.ts /.gitignore: -------------------------------------------------------------------------------- 1 | /npm 2 | deno.lock 3 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | export * from "./home_url.ts"; 2 | export * from "./commit_url.ts"; 3 | export * from "./object_url.ts"; 4 | export * from "./pull_request_url.ts"; 5 | export * from "./util.ts"; 6 | -------------------------------------------------------------------------------- /deno.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "lock": false, 3 | "name": "@lambdalisue/git-browse", 4 | "version": "0.0.0", 5 | "exports": { 6 | ".": "./mod.ts", 7 | "./cli": "./bin/browse.ts" 8 | }, 9 | "tasks": { 10 | "test": "deno test -A --parallel --doc", 11 | "check": "deno check **/*.ts", 12 | "upgrade": "deno run -A https://deno.land/x/udd/main.ts **/*.ts" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /process_test.ts: -------------------------------------------------------------------------------- 1 | import { assert, assertRejects } from "jsr:@std/assert@^0.221.0"; 2 | import { execute, ExecuteError } from "./process.ts"; 3 | 4 | Deno.test("execute() runs 'git' and return a stdout on success", async () => { 5 | const stdout = await execute(["version"]); 6 | assert(stdout.startsWith("git version")); 7 | }); 8 | 9 | Deno.test("execute() runs 'git' and throw ExecuteError on fail", async () => { 10 | await assertRejects(async () => { 11 | await execute(["no-such-command"]); 12 | }, ExecuteError); 13 | }); 14 | -------------------------------------------------------------------------------- /.github/workflows/jsr.yml: -------------------------------------------------------------------------------- 1 | name: jsr 2 | 3 | env: 4 | DENO_VERSION: 1.x 5 | 6 | on: 7 | push: 8 | tags: 9 | - "v*" 10 | 11 | permissions: 12 | contents: read 13 | id-token: write 14 | 15 | jobs: 16 | publish: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 0 22 | - uses: denoland/setup-deno@v1 23 | with: 24 | deno-version: ${{ env.DENO_VERSION }} 25 | - name: Publish 26 | run: | 27 | deno run -A jsr:@david/publish-on-tag@0.1.3 28 | -------------------------------------------------------------------------------- /.gitmessage: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Guide (v1.0) 4 | # 5 | # 👍 :+1: Apply changes. 6 | # 7 | # 🌿 :herb: Add or update things for tests. 8 | # ☕ :coffee: Add or update things for developments. 9 | # 📦 :package: Add or update dependencies. 10 | # 📝 :memo: Add or update documentations. 11 | # 12 | # 🐛 :bug: Bugfixes. 13 | # 💋 :kiss: Critical hotfixes. 14 | # 🚿 :shower: Remove features, codes, or files. 15 | # 16 | # 🚀 :rocket: Improve performance. 17 | # 💪 :muscle: Refactor codes. 18 | # 💥 :boom: Breaking changes. 19 | # 💩 :poop: Bad codes needs to be improved. 20 | # 21 | # How to use: 22 | # git config commit.template .gitmessage 23 | # 24 | # Reference: 25 | # https://github.com/lambdalisue/emojiprefix 26 | -------------------------------------------------------------------------------- /home_url.ts: -------------------------------------------------------------------------------- 1 | import type { ExecuteOptions } from "./process.ts"; 2 | import { getHostingService } from "./hosting_service/mod.ts"; 3 | import { getRemoteContains, getRemoteFetchURL } from "./util.ts"; 4 | 5 | export type Options = ExecuteOptions & { 6 | remote?: string; 7 | aliases?: Record; 8 | }; 9 | 10 | export async function getHomeURL( 11 | options: Options = {}, 12 | ): Promise { 13 | const remote = options.remote ?? 14 | await getRemoteContains("HEAD", options) ?? 15 | "origin"; 16 | const fetchURL = await getRemoteFetchURL(remote, options); 17 | if (!fetchURL) { 18 | throw new Error(`No remote '${remote}' found or failed to get fetch URL.`); 19 | } 20 | const hostingService = getHostingService(fetchURL, options); 21 | return hostingService.getHomeURL(fetchURL); 22 | } 23 | -------------------------------------------------------------------------------- /commit_url.ts: -------------------------------------------------------------------------------- 1 | import type { ExecuteOptions } from "./process.ts"; 2 | import { getHostingService } from "./hosting_service/mod.ts"; 3 | import { getRemoteContains, getRemoteFetchURL } from "./util.ts"; 4 | 5 | type Options = ExecuteOptions & { 6 | remote?: string; 7 | aliases?: Record; 8 | }; 9 | 10 | export async function getCommitURL( 11 | commitish: string, 12 | options: Options = {}, 13 | ): Promise { 14 | const remote = options.remote ?? 15 | await getRemoteContains(commitish, options) ?? 16 | "origin"; 17 | const fetchURL = await getRemoteFetchURL(remote, options); 18 | if (!fetchURL) { 19 | throw new Error(`No remote '${remote}' found`); 20 | } 21 | const hostingService = getHostingService(fetchURL, options); 22 | return hostingService.getCommitURL(fetchURL, commitish); 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | env: 4 | DENO_VERSION: 1.x 5 | 6 | on: 7 | schedule: 8 | - cron: "0 7 * * 0" 9 | push: 10 | branches: 11 | - main 12 | pull_request: 13 | 14 | jobs: 15 | check: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: denoland/setup-deno@v1 20 | with: 21 | deno-version: ${{ env.DENO_VERSION }} 22 | - name: Format 23 | run: | 24 | deno fmt --check 25 | - name: Lint 26 | run: deno lint 27 | - name: Type check 28 | run: deno task check 29 | 30 | test: 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v4 34 | with: 35 | fetch-depth: 0 36 | - uses: denoland/setup-deno@v1 37 | with: 38 | deno-version: ${{ env.DENO_VERSION }} 39 | - name: Test 40 | run: | 41 | deno task test 42 | timeout-minutes: 5 43 | - name: JSR publish (dry-run) 44 | run: | 45 | deno publish --dry-run 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023 Alisue 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | 21 | -------------------------------------------------------------------------------- /.github/workflows/udd.yml: -------------------------------------------------------------------------------- 1 | name: Update 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * *" 6 | workflow_dispatch: 7 | 8 | jobs: 9 | udd: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: denoland/setup-deno@v1 14 | with: 15 | deno-version: "1.x" 16 | - name: Update dependencies 17 | run: | 18 | deno task upgrade > ../output.txt 19 | env: 20 | NO_COLOR: 1 21 | - name: Read ../output.txt 22 | id: log 23 | uses: juliangruber/read-file-action@v1 24 | with: 25 | path: ../output.txt 26 | - name: Commit changes 27 | run: | 28 | git config user.name '${{ github.actor }}' 29 | git config user.email '${{ github.actor }}@users.noreply.github.com' 30 | git commit -a -F- <; 7 | 8 | export function execute( 9 | args: string[], 10 | options: ExecuteOptions = {}, 11 | ): Promise { 12 | return _internals.execute(args, options); 13 | } 14 | 15 | async function _execute( 16 | args: string[], 17 | options: ExecuteOptions = {}, 18 | ): Promise { 19 | // --literal-pathspecs 20 | // Treat pathspecs literally (i.e. no globbing, no pathspec magic). 21 | // This is equivalent to setting the GIT_LITERAL_PATHSPECS environment 22 | // variable to 1. 23 | // 24 | // --no-optional-locks 25 | // Do not perform optional operations that require locks. This is 26 | // equivalent to setting the GIT_OPTIONAL_LOCKS to 0. 27 | const cmdArgs = [ 28 | "--no-pager", 29 | "--literal-pathspecs", 30 | "--no-optional-locks", 31 | ]; 32 | const command = new Deno.Command("git", { 33 | args: [...cmdArgs, ...args], 34 | stdin: "null", 35 | stdout: "piped", 36 | stderr: "piped", 37 | ...options, 38 | }); 39 | const { code, success, stdout, stderr } = await command.output(); 40 | const stdoutStr = decoder.decode(stdout); 41 | const stderrStr = decoder.decode(stderr); 42 | if (!success) { 43 | throw new ExecuteError(args, code, stdoutStr, stderrStr); 44 | } 45 | return stdoutStr; 46 | } 47 | 48 | export class ExecuteError extends Error { 49 | constructor( 50 | public args: string[], 51 | public code: number, 52 | public stdout: string, 53 | public stderr: string, 54 | ) { 55 | super(`[${code}]: ${stderr}`); 56 | this.name = this.constructor.name; 57 | } 58 | } 59 | 60 | // For internal stub testing 61 | export const _internals = { 62 | execute: _execute, 63 | }; 64 | -------------------------------------------------------------------------------- /object_url.ts: -------------------------------------------------------------------------------- 1 | import { execute, type ExecuteOptions } from "./process.ts"; 2 | import { getHostingService, type Range } from "./hosting_service/mod.ts"; 3 | import { getRemoteContains, getRemoteFetchURL } from "./util.ts"; 4 | 5 | type Options = ExecuteOptions & { 6 | remote?: string; 7 | permalink?: boolean; 8 | aliases?: Record; 9 | }; 10 | 11 | export async function getObjectURL( 12 | commitish: string, 13 | path: string, 14 | options: Options = {}, 15 | ): Promise { 16 | const remote = options.remote ?? 17 | await getRemoteContains(commitish, options) ?? 18 | "origin"; 19 | const fetchURL = await getRemoteFetchURL(remote, options); 20 | if (!fetchURL) { 21 | throw new Error(`No remote '${remote}' found`); 22 | } 23 | const hostingService = getHostingService(fetchURL, options); 24 | const [normPath, range] = parsePath(path); 25 | const objectType = await getObjectType(commitish, normPath, options); 26 | if (objectType === "tree") { 27 | return hostingService.getTreeURL(fetchURL, commitish, normPath); 28 | } 29 | return hostingService.getBlobURL(fetchURL, commitish, normPath, { range }); 30 | } 31 | 32 | type ObjectType = "blob" | "tree" | "commit"; 33 | 34 | async function getObjectType( 35 | commitish: string, 36 | path: string, 37 | options: ExecuteOptions = {}, 38 | ): Promise { 39 | const stdout = await execute( 40 | ["ls-tree", "-t", commitish, path], 41 | options, 42 | ); 43 | return stdout 44 | .trim() 45 | .split("\n") 46 | .find((line) => line.includes(path)) 47 | ?.split(" ") 48 | .at(1) as ObjectType; 49 | } 50 | 51 | function parsePath(path: string): [string, Range | undefined] { 52 | const m = path.match(/^(.*?):(\d+)(?::(\d+))?$/); 53 | if (!m) { 54 | return [path, undefined]; 55 | } else if (m[3]) { 56 | return [m[1], [Number(m[2]), Number(m[3])]]; 57 | } 58 | return [m[1], Number(m[2])]; 59 | } 60 | -------------------------------------------------------------------------------- /hosting_service/mod.ts: -------------------------------------------------------------------------------- 1 | import type { ExecuteOptions } from "../process.ts"; 2 | import { service as bitbucket_org } from "./services/bitbucket_org.ts"; 3 | import { service as github_com } from "./services/github_com.ts"; 4 | import { service as gitlab_com } from "./services/gitlab_com.ts"; 5 | 6 | const serviceMap = { 7 | bitbucket_org, 8 | github_com, 9 | gitlab_com, 10 | }; 11 | 12 | export type Range = number | [number, number]; 13 | 14 | export type HostingService = { 15 | getHomeURL(fetchURL: URL, options?: ExecuteOptions): Promise; 16 | 17 | getCommitURL( 18 | fetchURL: URL, 19 | commitish: string, 20 | options?: ExecuteOptions, 21 | ): Promise; 22 | 23 | getTreeURL( 24 | fetchURL: URL, 25 | commitish: string, 26 | path: string, 27 | options?: ExecuteOptions, 28 | ): Promise; 29 | 30 | getBlobURL( 31 | fetchURL: URL, 32 | commitish: string, 33 | path: string, 34 | options?: { range?: Range } & ExecuteOptions, 35 | ): Promise; 36 | 37 | getPullRequestURL?( 38 | fetchURL: URL, 39 | number: number, 40 | options?: ExecuteOptions, 41 | ): Promise; 42 | 43 | extractPullRequestID?( 44 | commit: string, 45 | ): number | undefined; 46 | }; 47 | 48 | /** 49 | * Get git hosting service from URL 50 | */ 51 | export function getHostingService( 52 | fetchURL: URL, 53 | { aliases }: { aliases?: Record } = {}, 54 | ): HostingService { 55 | const hostname = aliases?.[fetchURL.hostname] ?? fetchURL.hostname; 56 | const svcName = hostname.replace(/\W/g, "_"); 57 | // deno-lint-ignore no-explicit-any 58 | const svc = (serviceMap as any)[svcName]; 59 | if (!svc) { 60 | throw new UnsupportedHostingServiceError(hostname, svcName); 61 | } 62 | return svc; 63 | } 64 | 65 | export class UnsupportedHostingServiceError extends Error { 66 | constructor( 67 | public hostname: string, 68 | public svcName: string, 69 | ) { 70 | super(`Unsupported hosting service: ${hostname} (${svcName})`); 71 | this.name = this.constructor.name; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /hosting_service/services/bitbucket_org.ts: -------------------------------------------------------------------------------- 1 | import type { HostingService, Range } from "../mod.ts"; 2 | import type { ExecuteOptions } from "../../process.ts"; 3 | import { extname } from "jsr:@std/path@^0.221.0"; 4 | import { getCommitSHA1 } from "../../util.ts"; 5 | 6 | export const service: HostingService = { 7 | getHomeURL(fetchURL: URL, _options?: ExecuteOptions): Promise { 8 | return Promise.resolve(new URL(formatURLBase(fetchURL))); 9 | }, 10 | 11 | async getCommitURL( 12 | fetchURL: URL, 13 | commitish: string, 14 | options: ExecuteOptions = {}, 15 | ): Promise { 16 | const sha = await getCommitSHA1(commitish, options) ?? commitish; 17 | const urlBase = formatURLBase(fetchURL); 18 | const pathname = `commits/${sha}`; 19 | return Promise.resolve(new URL(`${urlBase}/${pathname}`)); 20 | }, 21 | 22 | getTreeURL( 23 | fetchURL: URL, 24 | commitish: string, 25 | path: string, 26 | _options?: ExecuteOptions, 27 | ): Promise { 28 | const urlBase = formatURLBase(fetchURL); 29 | const pathname = `src/${commitish}/${path}`; 30 | return Promise.resolve(new URL(`${urlBase}/${pathname}`)); 31 | }, 32 | 33 | getBlobURL( 34 | fetchURL: URL, 35 | commitish: string, 36 | path: string, 37 | { range }: { range?: Range } & ExecuteOptions = {}, 38 | ): Promise { 39 | const urlBase = formatURLBase(fetchURL); 40 | if (!range || extname(path) !== ".md") { 41 | const suffix = formatSuffix(range); 42 | const pathname = `src/${commitish}/${path}${suffix}`; 43 | return Promise.resolve(new URL(`${urlBase}/${pathname}`)); 44 | } 45 | // Bitbucket does not provide `?plain=1` like GitHub so we need to use annotation URL 46 | // instead to proerply select the line range of Markdown file 47 | const suffix = formatSuffix(range); 48 | const pathname = `annotate/${commitish}/${path}${suffix}`; 49 | return Promise.resolve(new URL(`${urlBase}/${pathname}`)); 50 | }, 51 | }; 52 | 53 | function formatURLBase(fetchURL: URL): string { 54 | const [owner, repo] = fetchURL.pathname.split("/").slice(1); 55 | return `https://${fetchURL.hostname}/${owner}/${repo.replace(/\.git$/, "")}`; 56 | } 57 | 58 | function formatSuffix(range: Range | undefined): string { 59 | if (Array.isArray(range)) { 60 | return `#lines-${range[0]}:${range[1]}`; 61 | } else if (range) { 62 | return `#lines-${range}`; 63 | } 64 | return ""; 65 | } 66 | -------------------------------------------------------------------------------- /hosting_service/services/github_com.ts: -------------------------------------------------------------------------------- 1 | import type { HostingService, Range } from "../mod.ts"; 2 | import type { ExecuteOptions } from "../../process.ts"; 3 | import { getCommitSHA1 } from "../../util.ts"; 4 | 5 | export const service: HostingService = { 6 | getHomeURL(fetchURL: URL, _options?: ExecuteOptions): Promise { 7 | return Promise.resolve(new URL(formatURLBase(fetchURL))); 8 | }, 9 | 10 | async getCommitURL( 11 | fetchURL: URL, 12 | commitish: string, 13 | options?: ExecuteOptions, 14 | ): Promise { 15 | const sha = await getCommitSHA1(commitish, options) ?? commitish; 16 | const urlBase = formatURLBase(fetchURL); 17 | const pathname = `commit/${sha}`; 18 | return Promise.resolve(new URL(`${urlBase}/${pathname}`)); 19 | }, 20 | 21 | getTreeURL( 22 | fetchURL: URL, 23 | commitish: string, 24 | path: string, 25 | _options?: ExecuteOptions, 26 | ): Promise { 27 | const urlBase = formatURLBase(fetchURL); 28 | const pathname = `tree/${commitish}/${path}`; 29 | return Promise.resolve(new URL(`${urlBase}/${pathname}`)); 30 | }, 31 | 32 | getBlobURL( 33 | fetchURL: URL, 34 | commitish: string, 35 | path: string, 36 | { range }: { range?: Range } & ExecuteOptions = {}, 37 | ): Promise { 38 | const urlBase = formatURLBase(fetchURL); 39 | const suffix = formatSuffix(range); 40 | const pathname = `blob/${commitish}/${path}${suffix}`; 41 | return Promise.resolve(new URL(`${urlBase}/${pathname}`)); 42 | }, 43 | 44 | getPullRequestURL( 45 | fetchURL: URL, 46 | n: number, 47 | _options?: ExecuteOptions, 48 | ): Promise { 49 | const urlBase = formatURLBase(fetchURL); 50 | const pathname = `pull/${n}`; 51 | return Promise.resolve(new URL(`${urlBase}/${pathname}`)); 52 | }, 53 | 54 | extractPullRequestID(commit: string): number | undefined { 55 | const m = commit.match(/Merge pull request #(\d+)/); 56 | if (m) { 57 | return Number(m[1]); 58 | } 59 | return undefined; 60 | }, 61 | }; 62 | 63 | function formatURLBase(fetchURL: URL): string { 64 | const [owner, repo] = fetchURL.pathname.split("/").slice(1); 65 | return `https://${fetchURL.hostname}/${owner}/${repo.replace(/\.git$/, "")}`; 66 | } 67 | 68 | function formatSuffix(range: Range | undefined): string { 69 | // Note: 70 | // Without `?plain=1`, GitHub shows the rendering result of content (e.g. Markdown) so that we 71 | // cannot specify the line range. 72 | if (Array.isArray(range)) { 73 | return `?plain=1#L${range[0]}-L${range[1]}`; 74 | } else if (range) { 75 | return `?plain=1#L${range}`; 76 | } 77 | return ""; 78 | } 79 | -------------------------------------------------------------------------------- /hosting_service/services/gitlab_com.ts: -------------------------------------------------------------------------------- 1 | import type { HostingService, Range } from "../mod.ts"; 2 | import type { ExecuteOptions } from "../../process.ts"; 3 | import { getCommitSHA1 } from "../../util.ts"; 4 | 5 | export const service: HostingService = { 6 | getHomeURL(fetchURL: URL, _options?: ExecuteOptions): Promise { 7 | return Promise.resolve(new URL(formatURLBase(fetchURL))); 8 | }, 9 | 10 | async getCommitURL( 11 | fetchURL: URL, 12 | commitish: string, 13 | options: ExecuteOptions = {}, 14 | ): Promise { 15 | const sha = await getCommitSHA1(commitish, options) ?? commitish; 16 | const urlBase = formatURLBase(fetchURL); 17 | const pathname = `-/commit/${sha}`; 18 | return Promise.resolve(new URL(`${urlBase}/${pathname}`)); 19 | }, 20 | 21 | getTreeURL( 22 | fetchURL: URL, 23 | commitish: string, 24 | path: string, 25 | _options?: ExecuteOptions, 26 | ): Promise { 27 | const urlBase = formatURLBase(fetchURL); 28 | const pathname = `-/tree/${commitish}/${path}`; 29 | return Promise.resolve(new URL(`${urlBase}/${pathname}`)); 30 | }, 31 | 32 | getBlobURL( 33 | fetchURL: URL, 34 | commitish: string, 35 | path: string, 36 | { range }: { range?: Range } & ExecuteOptions = {}, 37 | ): Promise { 38 | const urlBase = formatURLBase(fetchURL); 39 | const suffix = formatSuffix(range); 40 | const pathname = `-/blob/${commitish}/${path}${suffix}`; 41 | return Promise.resolve(new URL(`${urlBase}/${pathname}`)); 42 | }, 43 | 44 | getPullRequestURL( 45 | fetchURL: URL, 46 | n: number, 47 | _options?: ExecuteOptions, 48 | ): Promise { 49 | const urlBase = formatURLBase(fetchURL); 50 | const pathname = `-/merge_requests/${n}`; 51 | return Promise.resolve(new URL(`${urlBase}/${pathname}`)); 52 | }, 53 | 54 | extractPullRequestID(commit: string): number | undefined { 55 | const m = commit.match(/See merge request (?:.*)!(\d+)/); 56 | if (m) { 57 | return Number(m[1]); 58 | } 59 | return undefined; 60 | }, 61 | }; 62 | 63 | function formatURLBase(fetchURL: URL): string { 64 | const [owner, repo] = fetchURL.pathname.split("/").slice(1); 65 | return `https://${fetchURL.hostname}/${owner}/${repo.replace(/\.git$/, "")}`; 66 | } 67 | 68 | function formatSuffix(range: Range | undefined): string { 69 | // Note: 70 | // Without `?plain=1`, GitHub shows the rendering result of content (e.g. Markdown) so that we 71 | // cannot specify the line range. 72 | if (Array.isArray(range)) { 73 | return `?plain=1#L${range[0]}-${range[1]}`; 74 | } else if (range) { 75 | return `?plain=1#L${range}`; 76 | } 77 | return ""; 78 | } 79 | -------------------------------------------------------------------------------- /pull_request_url.ts: -------------------------------------------------------------------------------- 1 | import { execute, type ExecuteOptions } from "./process.ts"; 2 | import { 3 | getHostingService, 4 | type HostingService, 5 | } from "./hosting_service/mod.ts"; 6 | import { 7 | __throw, 8 | getCommitSHA1, 9 | getRemoteContains, 10 | getRemoteFetchURL, 11 | } from "./util.ts"; 12 | 13 | type Options = ExecuteOptions & { 14 | remote?: string; 15 | aliases?: Record; 16 | }; 17 | 18 | export async function getPullRequestURL( 19 | commitish: string, 20 | options: Options = {}, 21 | ): Promise { 22 | const remote = options.remote ?? 23 | await getRemoteContains(commitish, options) ?? 24 | "origin"; 25 | const fetchURL = await getRemoteFetchURL(remote, options); 26 | if (!fetchURL) { 27 | throw new Error(`No remote '${remote}' found`); 28 | } 29 | const hostingService = getHostingService(fetchURL, options); 30 | if (!hostingService.getPullRequestURL) { 31 | throw new Error( 32 | `Hosting service of ${fetchURL} has no pull request URL`, 33 | ); 34 | } 35 | const pr = await getPullRequestContains( 36 | hostingService, 37 | commitish, 38 | remote, 39 | options, 40 | ); 41 | if (!pr) { 42 | throw new Error(`No pull request found for ${commitish}`); 43 | } 44 | return hostingService.getPullRequestURL(fetchURL, pr); 45 | } 46 | 47 | async function getPullRequestContains( 48 | hostingService: HostingService, 49 | commitish: string, 50 | remote: string, 51 | options: ExecuteOptions = {}, 52 | ): Promise { 53 | if (!hostingService.extractPullRequestID) { 54 | throw new Error("Hosting service has no pull request ID extractor"); 55 | } 56 | const branch = await getRemoteDefaultBranch(remote, options) ?? "main"; 57 | const sha = await getCommitSHA1(commitish, options) ?? __throw( 58 | new Error(`No commit found for ${commitish}`), 59 | ); 60 | let stdout: string; 61 | // The sha may points to a merge commit itself. 62 | stdout = await execute( 63 | [ 64 | "log", 65 | "-1", 66 | sha, 67 | ], 68 | options, 69 | ); 70 | const pr = hostingService.extractPullRequestID(stdout); 71 | if (pr) { 72 | return pr; 73 | } 74 | // Try to find the merge commit that contains the sha 75 | const ancestraryPath = `${sha}...${remote}/${branch}`; 76 | stdout = await execute( 77 | [ 78 | "log", 79 | "--merges", 80 | "--reverse", 81 | "--ancestry-path", 82 | ancestraryPath, 83 | ], 84 | options, 85 | ); 86 | return hostingService.extractPullRequestID(stdout); 87 | } 88 | 89 | async function getRemoteDefaultBranch( 90 | remote: string, 91 | options: ExecuteOptions = {}, 92 | ): Promise { 93 | const stdout = await execute( 94 | ["remote", "show", remote], 95 | options, 96 | ); 97 | const m = stdout.match(/HEAD branch: (.*)/); 98 | if (!m) { 99 | return undefined; 100 | } 101 | return m[1].trim(); 102 | } 103 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # git-browse 2 | 3 | [![JSR](https://jsr.io/badges/@lambdalisue/git-browse)](https://jsr.io/@lambdalisue/git-browse) 4 | [![denoland](https://img.shields.io/github/v/release/lambdalisue/deno-git-browse?logo=deno&label=denoland)](https://deno.land/x/git_browse) 5 | [![deno doc](https://doc.deno.land/badge.svg)](https://doc.deno.land/https/deno.land/x/git_browse/mod.ts) 6 | [![Test](https://github.com/lambdalisue/deno-git-browse/workflows/Test/badge.svg)](https://github.com/lambdalisue/deno-git-browse/actions?query=workflow%3ATest) 7 | 8 | Open the URL of the hosting service for the repository using the system web 9 | browser. The following hosting services are currently supported: 10 | 11 | - GitHub (https://github.com) 12 | - GitLab (https://gitlab.com) 13 | - Bitbucket (https://bitbucket.org) 14 | 15 | See [./hosting_service/services](./hosting_service/services) and create a PR to 16 | support more hosting services. 17 | 18 | ## Usage 19 | 20 | ```console 21 | $ browse 22 | #=> Opens a tree page of the current working directory in the HEAD commit of the current branch 23 | 24 | $ browse --path=README.md 25 | #=> Opens a blob page of README.md in the HEAD commit of the current branch 26 | 27 | $ browse --path=README.md:10 28 | #=> Opens a blob page of README.md with line 10 in the HEAD commit of the current branch 29 | 30 | $ browse --path=README.md:10:20 31 | #=> Opens a blob page of README.md with lines 10 to 20 in the HEAD commit of the current branch 32 | 33 | $ browse --path=README.md my-awesome-branch 34 | #=> Opens a blob page of README.md in the HEAD commit of my-awesome-branch branch 35 | 36 | $ browse --path=README.md fd28fa8 37 | #=> Opens a blob page of README.md in the fd28fa8 commit 38 | 39 | $ browse --home 40 | #=> Opens the home page of the repository 41 | 42 | $ browse --commit 43 | #=> Opens a commit page of the current branch 44 | 45 | $ browse --commit my-awesome-branch 46 | #=> Opens a commit page of my-awesome-branch branch 47 | 48 | $ browse --commit fd28fa8 49 | #=> Opens a commit page of fd28fa8 commit 50 | 51 | $ browse --pr 52 | #=> Opens a pull request page that contains the HEAD commit of the current branch 53 | 54 | $ browse --pr my-awesome-branch 55 | #=> Opens a pull request page that contains the HEAD commit of my-awesome-branch branch 56 | 57 | $ browse --pr fd28fa8 58 | #=> Opens a pull request page that contains the fd28fa8 commit 59 | ``` 60 | 61 | ## Install 62 | 63 | ### As a git alias 64 | 65 | Add the followings to your `.gitconfig` 66 | 67 | ```gitconfig 68 | [alias] 69 | browse = "!deno run --allow-net --allow-run --allow-read --allow-env jsr:@lambdalisue/git-browse/cli" 70 | ``` 71 | 72 | Then use it as `git browse` like 73 | 74 | ```console 75 | $ git browse --help 76 | ``` 77 | 78 | ### As an isolated command 79 | 80 | Use `deno install` command to install the command. 81 | 82 | ```console 83 | $ deno install --allow-net --allow-run --allow-read --allow-env -n browse jsr:@lambdalisue/git-browse/cli 84 | ``` 85 | 86 | Then use it as `browse` like 87 | 88 | ```console 89 | $ browse --help 90 | ``` 91 | 92 | ## License 93 | 94 | The code follows MIT license written in [LICENSE](./LICENSE). Contributors need 95 | to agree that any modifications sent in this repository follow the license. 96 | -------------------------------------------------------------------------------- /bin/browse.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S deno run --allow-run --allow-read --allow-env 2 | import { parseArgs } from "jsr:@std/cli@^0.221.0"; 3 | import { join } from "jsr:@std/path@^0.221.0"; 4 | import { ensure, is } from "jsr:@core/unknownutil@^3.17.2"; 5 | import { systemopen } from "jsr:@lambdalisue/systemopen@^1.0.0"; 6 | import { dir } from "jsr:@cross/dir@^1.1.0"; 7 | import { 8 | getCommitAbbrevRef, 9 | getCommitSHA1, 10 | getCommitURL, 11 | getHomeURL, 12 | getObjectURL, 13 | getPullRequestURL, 14 | } from "../mod.ts"; 15 | import { __throw } from "../util.ts"; 16 | 17 | export type Options = { 18 | cwd?: string; 19 | remote?: string; 20 | path?: string; 21 | home?: boolean; 22 | commit?: boolean; 23 | pr?: boolean; 24 | permalink?: boolean; 25 | aliases?: Record; 26 | }; 27 | 28 | export async function getURL( 29 | commitish: string, 30 | options: Options = {}, 31 | ): Promise { 32 | options.aliases = options.aliases ?? await readAliasesFile(); 33 | if (options.home) { 34 | return getHomeURL(options); 35 | } 36 | if (options.pr) { 37 | commitish = await getCommitSHA1(commitish, options) ?? __throw( 38 | new Error(`No commit found for ${commitish}`), 39 | ); 40 | return getPullRequestURL(commitish, options); 41 | } 42 | commitish = options.permalink 43 | ? await getCommitSHA1(commitish, options) ?? __throw( 44 | new Error(`No commit found for ${commitish}`), 45 | ) 46 | : await getCommitAbbrevRef(commitish, options) ?? __throw( 47 | new Error(`No commit found for ${commitish}`), 48 | ); 49 | if (options.commit) { 50 | return getCommitURL(commitish, options); 51 | } 52 | return getObjectURL(commitish, options.path ?? ".", options); 53 | } 54 | 55 | export async function readAliasesFile(): Promise> { 56 | const cdir = await dir("config"); 57 | if (!cdir) { 58 | return {}; 59 | } 60 | try { 61 | return JSON.parse( 62 | await Deno.readTextFile(join(cdir, "browse", "aliases.json")), 63 | ); 64 | } catch (err) { 65 | if (err instanceof Deno.errors.NotFound) { 66 | return {}; 67 | } 68 | throw err; 69 | } 70 | } 71 | 72 | async function main(args: string[]): Promise { 73 | const opts = parseArgs(args, { 74 | boolean: [ 75 | "commit", 76 | "help", 77 | "home", 78 | "no-browser", 79 | "permalink", 80 | "pr", 81 | ], 82 | string: ["remote", "path"], 83 | alias: { 84 | "help": ["h"], 85 | "no-browser": ["n"], 86 | }, 87 | }); 88 | if (opts.help) { 89 | const resp = await fetch(new URL("./browse_usage.txt", import.meta.url)); 90 | const text = await resp.text(); 91 | console.log(text); 92 | return; 93 | } 94 | 95 | const commitish = ensure(opts._.at(0) ?? "HEAD", is.String, { 96 | message: "Commitish must be a string", 97 | }); 98 | const url = await getURL(commitish, opts); 99 | 100 | if (opts["no-browser"]) { 101 | console.log(url.href); 102 | } else { 103 | await systemopen(url.href); 104 | } 105 | } 106 | 107 | if (import.meta.main) { 108 | try { 109 | await main(Deno.args); 110 | } catch (err) { 111 | if (err instanceof Error) { 112 | console.error(err.message); 113 | } else { 114 | console.error(err); 115 | } 116 | Deno.exit(1); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /bin/browse_usage.txt: -------------------------------------------------------------------------------- 1 | Open the URL of the hosting service for the repository using the system web browser. 2 | 3 | USAGE 4 | browse [options] [--path=PATH] [COMMITISH] 5 | browse [options] --home 6 | browse [options] --commit [COMMITISH] 7 | browse [options] --pr [COMMITISH] 8 | 9 | OPTIONS 10 | -h, --help Display this help message. 11 | -n, --no-browser Print a URL instead of opening it in the browser. 12 | --permalink Use a permalink instead of a regular URL. 13 | --remote=REMOTE Use a URL from the specified remote repository. 14 | 15 | ALIASES 16 | To enable support for non-standard repository URLs (e.g., GitHub Enterprise), you can create aliases 17 | for the repository URL. To define these aliases, create a file named "aliases.json" within the 18 | specified configuration directory, as illustrated below: 19 | 20 | { 21 | "my.github.com": "github.com", 22 | // Add more aliases as needed 23 | } 24 | 25 | This file needs to be stored in the "browse" directory within the configuration directory, as 26 | indicated by the following paths based on your operating system: 27 | 28 | ┌─────────┬────────────────────────────────────────────┬──────────────────────────────────────────┐ 29 | │ OS │ Value │ Example │ 30 | ├─────────┼────────────────────────────────────────────┼──────────────────────────────────────────┤ 31 | │ Linux │ ($XDG_CONFIG_HOME or $HOME/.config)/browse │ /home/alisue/.config/browse │ 32 | ├─────────┼────────────────────────────────────────────┼──────────────────────────────────────────┤ 33 | │ macOS │ $HOME/Library/Preferences/browse │ /Users/alisue/Library/Preferences/browse │ 34 | ├─────────┼────────────────────────────────────────────┼──────────────────────────────────────────┤ 35 | │ Windows │ $APPDATA\browse │ C:\Users\alisue\AppData\Roaming\browse │ 36 | └─────────┴────────────────────────────────────────────┴──────────────────────────────────────────┘ 37 | 38 | EXAMPLES 39 | $ browse 40 | #=> Opens a tree page of the current working directory in the HEAD commit of the current branch 41 | 42 | $ browse --path=README.md 43 | #=> Opens a blob page of README.md in the HEAD commit of the current branch 44 | 45 | $ browse --path=README.md:10 46 | #=> Opens a blob page of README.md with line 10 in the HEAD commit of the current branch 47 | 48 | $ browse --path=README.md:10:20 49 | #=> Opens a blob page of README.md with lines 10 to 20 in the HEAD commit of the current branch 50 | 51 | $ browse --path=README.md my-awesome-branch 52 | #=> Opens a blob page of README.md in the HEAD commit of my-awesome-branch branch 53 | 54 | $ browse --path=README.md fd28fa8 55 | #=> Opens a blob page of README.md in the fd28fa8 commit 56 | 57 | $ browse --home 58 | #=> Opens the home page of the repository 59 | 60 | $ browse --commit 61 | #=> Opens a commit page of the current branch 62 | 63 | $ browse --commit my-awesome-branch 64 | #=> Opens a commit page of my-awesome-branch branch 65 | 66 | $ browse --commit fd28fa8 67 | #=> Opens a commit page of fd28fa8 commit 68 | 69 | $ browse --pr 70 | #=> Opens a pull request page that contains the HEAD commit of the current branch 71 | 72 | $ browse --pr my-awesome-branch 73 | #=> Opens a pull request page that contains the HEAD commit of my-awesome-branch branch 74 | 75 | $ browse --pr fd28fa8 76 | #=> Opens a pull request page that contains the fd28fa8 commit 77 | -------------------------------------------------------------------------------- /util.ts: -------------------------------------------------------------------------------- 1 | import { execute, ExecuteError, type ExecuteOptions } from "./process.ts"; 2 | 3 | /** 4 | * A helper function to throw error 5 | * 6 | * https://github.com/tc39/proposal-throw-expressions 7 | */ 8 | export function __throw(err: unknown): never { 9 | throw err; 10 | } 11 | 12 | /** 13 | * Returns a remote name that contains the given commitish. 14 | * 15 | * It returns undefined if there is no remote that contains the commitish. 16 | * If there are multiple remotes that contain the commitish, it prefer to return "origin" or the first one. 17 | */ 18 | export async function getRemoteContains( 19 | commitish: string, 20 | options: ExecuteOptions = {}, 21 | ): Promise { 22 | try { 23 | const branches = (await execute( 24 | ["branch", "-r", "--contains", commitish, "--format=%(refname:short)"], 25 | options, 26 | )).trim().split("\n"); 27 | const remotes = (await execute(["remote"], options)).trim().split("\n"); 28 | const remoteContains = remotes.filter((remote) => { 29 | return branches.some((branch) => branch.startsWith(`${remote}/`)); 30 | }); 31 | // Prefer "origin" if it exists 32 | return remoteContains.includes("origin") ? "origin" : remoteContains.at(0); 33 | } catch (err: unknown) { 34 | if (err instanceof ExecuteError && err.code === 129) { 35 | // error: malformed object name ... 36 | return undefined; 37 | } 38 | throw err; 39 | } 40 | } 41 | 42 | /** 43 | * Returns a remote's fetch URL. 44 | * 45 | * It returns undefined if the remote does not exist. 46 | * If the remote has multiple URLs, it returns the first one. 47 | */ 48 | export async function getRemoteFetchURL( 49 | remote: string, 50 | options: ExecuteOptions = {}, 51 | ): Promise { 52 | try { 53 | const stdout = await execute( 54 | ["remote", "get-url", remote], 55 | options, 56 | ); 57 | // a remote always has at least one URL 58 | const url = stdout.trim().split("\n").at(0)!; 59 | return new URL(url.replace(/^git@([^:]+):(.*)\.git$/, "ssh://git@$1/$2")); 60 | } catch (err: unknown) { 61 | if (err instanceof ExecuteError && err.code === 2) { 62 | // error: No such remote '...' 63 | return undefined; 64 | } 65 | throw err; 66 | } 67 | } 68 | 69 | /** 70 | * Returns a commit's SHA-1. 71 | * 72 | * It returns undefined if the commitish does not exist. 73 | */ 74 | export async function getCommitSHA1( 75 | commitish: string, 76 | options: ExecuteOptions = {}, 77 | ): Promise { 78 | try { 79 | const stdout = await execute( 80 | ["rev-parse", commitish], 81 | options, 82 | ); 83 | return stdout.trim(); 84 | } catch (err: unknown) { 85 | if (err instanceof ExecuteError && err.code === 128) { 86 | // ... 87 | // fatal: ambiguous argument '...': unknown revision or path not in the working tree. 88 | // Use '--' to separate paths from revisions, like this: 89 | // 'git [...] -- [...]' 90 | return undefined; 91 | } 92 | throw err; 93 | } 94 | } 95 | 96 | /** 97 | * Returns a commit's abbrev ref 98 | * 99 | * It returns undefined if the commitish does not exist. 100 | */ 101 | export async function getCommitAbbrevRef( 102 | commitish: string, 103 | options: ExecuteOptions = {}, 104 | ): Promise { 105 | try { 106 | const stdout = await execute( 107 | ["rev-parse", "--abbrev-ref", commitish], 108 | options, 109 | ); 110 | return stdout.trim() || commitish; 111 | } catch (err: unknown) { 112 | if (err instanceof ExecuteError && err.code === 128) { 113 | // ... 114 | // fatal: ambiguous argument '...': unknown revision or path not in the working tree. 115 | // Use '--' to separate paths from revisions, like this: 116 | // 'git [...] -- [...]' 117 | return undefined; 118 | } 119 | throw err; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /hosting_service/mod_test.ts: -------------------------------------------------------------------------------- 1 | import { assertThrows } from "jsr:@std/assert@^0.221.0"; 2 | import { assertSnapshot } from "jsr:@std/testing@^0.221.0/snapshot"; 3 | import { getHostingService, UnsupportedHostingServiceError } from "./mod.ts"; 4 | 5 | Deno.test("getHostingService", async (t) => { 6 | await t.step("throws error for unsupported hosting service", () => { 7 | const url = new URL("https://example.com/lambdalisue/deno-git-browse"); 8 | assertThrows( 9 | () => { 10 | return getHostingService(url); 11 | }, 12 | UnsupportedHostingServiceError, 13 | ); 14 | }); 15 | 16 | const urls = [ 17 | new URL("ssh://git@github.com/lambdalisue/deno-git-browse"), 18 | new URL("https://github.com/lambdalisue/deno-git-browse"), 19 | new URL("ssh://git@gitlab.com/lambdalisue/deno-git-browse"), 20 | new URL("https://gitlab.com/lambdalisue/deno-git-browse"), 21 | new URL("ssh://git@bitbucket.org/lambdalisue/deno-git-browse"), 22 | new URL("https://bitbucket.org/lambdalisue/deno-git-browse"), 23 | ]; 24 | for (const url of urls) { 25 | const svc = getHostingService(url); 26 | 27 | await t.step(`getHomeURL for ${url}`, async () => { 28 | const result = await svc.getHomeURL(url); 29 | await assertSnapshot(t, { 30 | url: url.href, 31 | result: result.href, 32 | }); 33 | }); 34 | 35 | await t.step(`getCommitURL for ${url}`, async () => { 36 | const result = await svc.getCommitURL(url, "v0.1.0"); 37 | await assertSnapshot(t, { 38 | url: url.href, 39 | result: result.href, 40 | }); 41 | }); 42 | 43 | await t.step(`getTreeURL for ${url}`, async () => { 44 | const result = await svc.getTreeURL(url, "v0.1.0", "bin"); 45 | await assertSnapshot(t, { 46 | url: url.href, 47 | result: result.href, 48 | }); 49 | }); 50 | 51 | await t.step(`getBlobURL for ${url} (Markdown)`, async () => { 52 | const result = await svc.getBlobURL(url, "v0.1.0", "README.md"); 53 | await assertSnapshot(t, { 54 | url: url.href, 55 | result: result.href, 56 | }); 57 | }); 58 | 59 | await t.step( 60 | `getBlobURL with lineStart for ${url} (Markdown)`, 61 | async () => { 62 | const result = await svc.getBlobURL(url, "v0.1.0", "README.md", { 63 | range: 10, 64 | }); 65 | await assertSnapshot(t, { 66 | url: url.href, 67 | result: result.href, 68 | }); 69 | }, 70 | ); 71 | 72 | await t.step( 73 | `getBlobURL with lineStart/lineEnd for ${url} (Markdown)`, 74 | async () => { 75 | const result = await svc.getBlobURL(url, "v0.1.0", "README.md", { 76 | range: [10, 20], 77 | }); 78 | await assertSnapshot(t, { 79 | url: url.href, 80 | result: result.href, 81 | }); 82 | }, 83 | ); 84 | 85 | await t.step(`getBlobURL for ${url}`, async () => { 86 | const result = await svc.getBlobURL(url, "v0.1.0", "bin/browse.ts"); 87 | await assertSnapshot(t, { 88 | url: url.href, 89 | result: result.href, 90 | }); 91 | }); 92 | 93 | await t.step(`getBlobURL with lineStart for ${url}`, async () => { 94 | const result = await svc.getBlobURL(url, "v0.1.0", "bin/browse.ts", { 95 | range: 10, 96 | }); 97 | await assertSnapshot(t, { 98 | url: url.href, 99 | result: result.href, 100 | }); 101 | }); 102 | 103 | await t.step( 104 | `getBlobURL with lineStart/lineEnd for ${url}`, 105 | async () => { 106 | const result = await svc.getBlobURL(url, "v0.1.0", "bin/browse.ts", { 107 | range: [10, 20], 108 | }); 109 | await assertSnapshot(t, { 110 | url: url.href, 111 | result: result.href, 112 | }); 113 | }, 114 | ); 115 | 116 | if (svc.getPullRequestURL) { 117 | await t.step(`getPullRequestURL for ${url}`, async () => { 118 | const result = await svc.getPullRequestURL!(url, 1); 119 | await assertSnapshot(t, { 120 | url: url.href, 121 | result: result.href, 122 | }); 123 | }); 124 | } 125 | } 126 | }); 127 | 128 | Deno.test("getHostingService with alias", async (t) => { 129 | const urls = [ 130 | new URL("https://my-github.com/lambdalisue/deno-git-browse"), 131 | ]; 132 | const aliases = { 133 | "my-github.com": "github.com", 134 | }; 135 | 136 | for (const url of urls) { 137 | const svc = getHostingService(url, { aliases }); 138 | 139 | await t.step(`getHomeURL for ${url}`, async () => { 140 | const result = await svc.getHomeURL(url); 141 | await assertSnapshot(t, { 142 | url: url.href, 143 | result: result.href, 144 | }); 145 | }); 146 | 147 | await t.step(`getCommitURL for ${url}`, async () => { 148 | const result = await svc.getCommitURL(url, "v0.1.0"); 149 | await assertSnapshot(t, { 150 | url: url.href, 151 | result: result.href, 152 | }); 153 | }); 154 | 155 | await t.step(`getTreeURL for ${url}`, async () => { 156 | const result = await svc.getTreeURL(url, "v0.1.0", "bin"); 157 | await assertSnapshot(t, { 158 | url: url.href, 159 | result: result.href, 160 | }); 161 | }); 162 | 163 | await t.step(`getBlobURL for ${url}`, async () => { 164 | const result = await svc.getBlobURL(url, "v0.1.0", "README.md"); 165 | await assertSnapshot(t, { 166 | url: url.href, 167 | result: result.href, 168 | }); 169 | }); 170 | 171 | await t.step(`getBlobURL with lineStart for ${url}`, async () => { 172 | const result = await svc.getBlobURL(url, "v0.1.0", "README.md", { 173 | range: 10, 174 | }); 175 | await assertSnapshot(t, { 176 | url: url.href, 177 | result: result.href, 178 | }); 179 | }); 180 | 181 | await t.step( 182 | `getBlobURL with lineStart/lineEnd for ${url}`, 183 | async () => { 184 | const result = await svc.getBlobURL(url, "v0.1.0", "README.md", { 185 | range: [10, 20], 186 | }); 187 | await assertSnapshot(t, { 188 | url: url.href, 189 | result: result.href, 190 | }); 191 | }, 192 | ); 193 | 194 | if (svc.getPullRequestURL) { 195 | await t.step(`getPullRequestURL for ${url}`, async () => { 196 | const result = await svc.getPullRequestURL!(url, 1); 197 | await assertSnapshot(t, { 198 | url: url.href, 199 | result: result.href, 200 | }); 201 | }); 202 | } 203 | } 204 | }); 205 | -------------------------------------------------------------------------------- /util_test.ts: -------------------------------------------------------------------------------- 1 | import { stub } from "jsr:@std/testing@^0.221.0/mock"; 2 | import { assertEquals, unreachable } from "jsr:@std/assert@^0.221.0"; 3 | import { _internals, ExecuteError } from "./process.ts"; 4 | import { 5 | getCommitAbbrevRef, 6 | getCommitSHA1, 7 | getRemoteContains, 8 | getRemoteFetchURL, 9 | } from "./util.ts"; 10 | 11 | Deno.test("getRemoteContains", async (t) => { 12 | await t.step( 13 | "returns undefined if no remote contains the commitish", 14 | async () => { 15 | const executeStub = stub(_internals, "execute", (args, _options) => { 16 | if (args.at(0) === "remote") { 17 | return Promise.resolve("origin\nfork\n"); 18 | } 19 | if (args.at(0) === "branch") { 20 | throw new ExecuteError( 21 | args, 22 | 129, 23 | "", 24 | "error: malformed object name <>\n", 25 | ); 26 | } 27 | unreachable(); 28 | }); 29 | try { 30 | const remote = await getRemoteContains("<>"); 31 | assertEquals(remote, undefined); 32 | } finally { 33 | executeStub.restore(); 34 | } 35 | }, 36 | ); 37 | 38 | await t.step( 39 | "returns 'origin' if 'origin/my-awesome-branch' contains the commitish", 40 | async () => { 41 | const executeStub = stub(_internals, "execute", (args, _options) => { 42 | if (args.at(0) === "remote") { 43 | return Promise.resolve("origin\nfork\n"); 44 | } 45 | if (args.at(0) === "branch") { 46 | return Promise.resolve("origin/my-awesome-branch\n"); 47 | } 48 | unreachable(); 49 | }); 50 | try { 51 | const remote = await getRemoteContains("<>"); 52 | assertEquals(remote, "origin"); 53 | } finally { 54 | executeStub.restore(); 55 | } 56 | }, 57 | ); 58 | 59 | await t.step( 60 | "returns 'origin' if 'origin/HEAD' and 'origin/my-awesome-branch' contains the commitish", 61 | async () => { 62 | const executeStub = stub(_internals, "execute", (args, _options) => { 63 | if (args.at(0) === "remote") { 64 | return Promise.resolve("origin\nfork\n"); 65 | } 66 | if (args.at(0) === "branch") { 67 | return Promise.resolve("origin/HEAD\norigin/my-awesome-branch\n"); 68 | } 69 | unreachable(); 70 | }); 71 | try { 72 | const remote = await getRemoteContains("<>"); 73 | assertEquals(remote, "origin"); 74 | } finally { 75 | executeStub.restore(); 76 | } 77 | }, 78 | ); 79 | 80 | await t.step( 81 | "returns 'fork' if 'fork/my-awesome-branch' contains the commitish", 82 | async () => { 83 | const executeStub = stub(_internals, "execute", (args, _options) => { 84 | if (args.at(0) === "remote") { 85 | return Promise.resolve("origin\nfork\n"); 86 | } 87 | if (args.at(0) === "branch") { 88 | return Promise.resolve("fork/my-awesome-branch\n"); 89 | } 90 | unreachable(); 91 | }); 92 | try { 93 | const remote = await getRemoteContains("<>"); 94 | assertEquals(remote, "fork"); 95 | } finally { 96 | executeStub.restore(); 97 | } 98 | }, 99 | ); 100 | 101 | await t.step( 102 | "returns 'origin' if 'origin/feature/my-awesome-branch' contains the commitish (#9)", 103 | async () => { 104 | const executeStub = stub(_internals, "execute", (args, _options) => { 105 | if (args.at(0) === "remote") { 106 | return Promise.resolve("origin\nfork\n"); 107 | } 108 | if (args.at(0) === "branch") { 109 | return Promise.resolve("origin/feature/my-awesome-branch\n"); 110 | } 111 | unreachable(); 112 | }); 113 | try { 114 | const remote = await getRemoteContains("<>"); 115 | assertEquals(remote, "origin"); 116 | } finally { 117 | executeStub.restore(); 118 | } 119 | }, 120 | ); 121 | 122 | await t.step( 123 | "returns 'fork/my-awesome-fork' if 'fork/my-awesome-fork/feature/my-awesome-branch' contains the commitish", 124 | async () => { 125 | const executeStub = stub(_internals, "execute", (args, _options) => { 126 | if (args.at(0) === "remote") { 127 | return Promise.resolve("origin\nfork/my-awesome-fork\n"); 128 | } 129 | if (args.at(0) === "branch") { 130 | return Promise.resolve( 131 | "fork/my-awesome-fork/feature/my-awesome-branch\n", 132 | ); 133 | } 134 | unreachable(); 135 | }); 136 | try { 137 | const remote = await getRemoteContains("<>"); 138 | assertEquals(remote, "fork/my-awesome-fork"); 139 | } finally { 140 | executeStub.restore(); 141 | } 142 | }, 143 | ); 144 | 145 | await t.step( 146 | "returns 'origin' if 'origin/my-awesome-branch' and 'fork/my-awesome-branch' contains the commitish", 147 | async () => { 148 | const executeStub = stub(_internals, "execute", (args, _options) => { 149 | if (args.at(0) === "remote") { 150 | return Promise.resolve("origin\nfork\n"); 151 | } 152 | if (args.at(0) === "branch") { 153 | return Promise.resolve( 154 | "fork/my-awesome-branch\norigin/my-awesome-branch\n", 155 | ); 156 | } 157 | unreachable(); 158 | }); 159 | try { 160 | const remote = await getRemoteContains("<>"); 161 | assertEquals(remote, "origin"); 162 | } finally { 163 | executeStub.restore(); 164 | } 165 | }, 166 | ); 167 | }); 168 | 169 | Deno.test("getRemoteFetchURL", async (t) => { 170 | await t.step( 171 | "returns undefined if no remote exists", 172 | async () => { 173 | const executeStub = stub(_internals, "execute", (args, _options) => { 174 | if (args.at(0) === "remote") { 175 | throw new ExecuteError( 176 | args, 177 | 2, 178 | "", 179 | "error: No such remote 'origin'\n", 180 | ); 181 | } 182 | unreachable(); 183 | }); 184 | try { 185 | const url = await getRemoteFetchURL("origin"); 186 | assertEquals(url, undefined); 187 | } finally { 188 | executeStub.restore(); 189 | } 190 | }, 191 | ); 192 | 193 | await t.step( 194 | "returns URL of the remote (HTTP URL)", 195 | async () => { 196 | const executeStub = stub(_internals, "execute", (args, _options) => { 197 | if (args.at(0) === "remote") { 198 | return Promise.resolve( 199 | "https://github.com/lambdalisue/deno-git-browse\n", 200 | ); 201 | } 202 | unreachable(); 203 | }); 204 | try { 205 | const url = await getRemoteFetchURL("origin"); 206 | assertEquals( 207 | url, 208 | new URL("https://github.com/lambdalisue/deno-git-browse"), 209 | ); 210 | } finally { 211 | executeStub.restore(); 212 | } 213 | }, 214 | ); 215 | 216 | await t.step( 217 | "returns URL of the remote (SSH URL)", 218 | async () => { 219 | const executeStub = stub(_internals, "execute", (args, _options) => { 220 | if (args.at(0) === "remote") { 221 | return Promise.resolve( 222 | "ssh://git@github.com/lambdalisue/deno-git-browse\n", 223 | ); 224 | } 225 | unreachable(); 226 | }); 227 | try { 228 | const url = await getRemoteFetchURL("origin"); 229 | assertEquals( 230 | url, 231 | new URL("ssh://git@github.com/lambdalisue/deno-git-browse"), 232 | ); 233 | } finally { 234 | executeStub.restore(); 235 | } 236 | }, 237 | ); 238 | 239 | await t.step( 240 | "returns URL of the remote (SSH Protocol)", 241 | async () => { 242 | const executeStub = stub(_internals, "execute", (args, _options) => { 243 | if (args.at(0) === "remote") { 244 | return Promise.resolve( 245 | "git@github.com:lambdalisue/deno-git-browse.git\n", 246 | ); 247 | } 248 | unreachable(); 249 | }); 250 | try { 251 | const url = await getRemoteFetchURL("origin"); 252 | assertEquals( 253 | url, 254 | new URL("ssh://git@github.com/lambdalisue/deno-git-browse"), 255 | ); 256 | } finally { 257 | executeStub.restore(); 258 | } 259 | }, 260 | ); 261 | }); 262 | 263 | Deno.test("getCommitSHA1", async (t) => { 264 | await t.step( 265 | "returns undefined when the commitish is not found", 266 | async () => { 267 | const executeStub = stub(_internals, "execute", (args, _options) => { 268 | if (args.at(0) === "rev-parse") { 269 | throw new ExecuteError( 270 | args, 271 | 128, 272 | "", 273 | "<>\nfatal: ambiguous argument '<>': unknown revision or path not in the working tree.\nUse '--' to separate paths from revisions, like this:\n'git [...] -- [...]'\n", 274 | ); 275 | } 276 | unreachable(); 277 | }); 278 | try { 279 | const sha1 = await getCommitSHA1("<>"); 280 | assertEquals(sha1, undefined); 281 | } finally { 282 | executeStub.restore(); 283 | } 284 | }, 285 | ); 286 | 287 | await t.step( 288 | "returns commit SHA1", 289 | async () => { 290 | const expect = "8e3367e40b91850d7f7864b4a8984d25f6e9419e"; 291 | const executeStub = stub(_internals, "execute", (args, _options) => { 292 | if (args.at(0) === "rev-parse") { 293 | return Promise.resolve(`${expect}\n`); 294 | } 295 | unreachable(); 296 | }); 297 | try { 298 | const sha1 = await getCommitSHA1("<>"); 299 | assertEquals(sha1, expect); 300 | } finally { 301 | executeStub.restore(); 302 | } 303 | }, 304 | ); 305 | }); 306 | 307 | Deno.test("getCommitAbbrevRef", async (t) => { 308 | await t.step( 309 | "returns undefined when the commitish is not found", 310 | async () => { 311 | const executeStub = stub(_internals, "execute", (args, _options) => { 312 | if (args.at(0) === "rev-parse") { 313 | throw new ExecuteError( 314 | args, 315 | 128, 316 | "", 317 | "<>\nfatal: ambiguous argument '<>': unknown revision or path not in the working tree.\nUse '--' to separate paths from revisions, like this:\n'git [...] -- [...]'\n", 318 | ); 319 | } 320 | unreachable(); 321 | }); 322 | try { 323 | const sha1 = await getCommitAbbrevRef("<>"); 324 | assertEquals(sha1, undefined); 325 | } finally { 326 | executeStub.restore(); 327 | } 328 | }, 329 | ); 330 | 331 | await t.step( 332 | "returns commit abbrev ref", 333 | async () => { 334 | const executeStub = stub(_internals, "execute", (args, _options) => { 335 | if (args.at(0) === "rev-parse") { 336 | return Promise.resolve(`my-awesome-branch\n`); 337 | } 338 | unreachable(); 339 | }); 340 | try { 341 | const sha1 = await getCommitAbbrevRef("<>"); 342 | assertEquals(sha1, "my-awesome-branch"); 343 | } finally { 344 | executeStub.restore(); 345 | } 346 | }, 347 | ); 348 | }); 349 | -------------------------------------------------------------------------------- /hosting_service/__snapshots__/mod_test.ts.snap: -------------------------------------------------------------------------------- 1 | export const snapshot = {}; 2 | 3 | snapshot[`getHostingService 1`] = ` 4 | { 5 | result: "https://github.com/lambdalisue/deno-git-browse", 6 | url: "ssh://git@github.com/lambdalisue/deno-git-browse", 7 | } 8 | `; 9 | 10 | snapshot[`getHostingService 2`] = ` 11 | { 12 | result: "https://github.com/lambdalisue/deno-git-browse/commit/2120c87633f4059656e8aada8eebe62768892b46", 13 | url: "ssh://git@github.com/lambdalisue/deno-git-browse", 14 | } 15 | `; 16 | 17 | snapshot[`getHostingService 3`] = ` 18 | { 19 | result: "https://github.com/lambdalisue/deno-git-browse/tree/v0.1.0/bin", 20 | url: "ssh://git@github.com/lambdalisue/deno-git-browse", 21 | } 22 | `; 23 | 24 | snapshot[`getHostingService 4`] = ` 25 | { 26 | result: "https://github.com/lambdalisue/deno-git-browse/blob/v0.1.0/README.md", 27 | url: "ssh://git@github.com/lambdalisue/deno-git-browse", 28 | } 29 | `; 30 | 31 | snapshot[`getHostingService 5`] = ` 32 | { 33 | result: "https://github.com/lambdalisue/deno-git-browse/blob/v0.1.0/README.md?plain=1#L10", 34 | url: "ssh://git@github.com/lambdalisue/deno-git-browse", 35 | } 36 | `; 37 | 38 | snapshot[`getHostingService 6`] = ` 39 | { 40 | result: "https://github.com/lambdalisue/deno-git-browse/blob/v0.1.0/README.md?plain=1#L10-L20", 41 | url: "ssh://git@github.com/lambdalisue/deno-git-browse", 42 | } 43 | `; 44 | 45 | snapshot[`getHostingService 7`] = ` 46 | { 47 | result: "https://github.com/lambdalisue/deno-git-browse/blob/v0.1.0/bin/browse.ts", 48 | url: "ssh://git@github.com/lambdalisue/deno-git-browse", 49 | } 50 | `; 51 | 52 | snapshot[`getHostingService 8`] = ` 53 | { 54 | result: "https://github.com/lambdalisue/deno-git-browse/blob/v0.1.0/bin/browse.ts?plain=1#L10", 55 | url: "ssh://git@github.com/lambdalisue/deno-git-browse", 56 | } 57 | `; 58 | 59 | snapshot[`getHostingService 9`] = ` 60 | { 61 | result: "https://github.com/lambdalisue/deno-git-browse/blob/v0.1.0/bin/browse.ts?plain=1#L10-L20", 62 | url: "ssh://git@github.com/lambdalisue/deno-git-browse", 63 | } 64 | `; 65 | 66 | snapshot[`getHostingService 10`] = ` 67 | { 68 | result: "https://github.com/lambdalisue/deno-git-browse/pull/1", 69 | url: "ssh://git@github.com/lambdalisue/deno-git-browse", 70 | } 71 | `; 72 | 73 | snapshot[`getHostingService 11`] = ` 74 | { 75 | result: "https://github.com/lambdalisue/deno-git-browse", 76 | url: "https://github.com/lambdalisue/deno-git-browse", 77 | } 78 | `; 79 | 80 | snapshot[`getHostingService 12`] = ` 81 | { 82 | result: "https://github.com/lambdalisue/deno-git-browse/commit/2120c87633f4059656e8aada8eebe62768892b46", 83 | url: "https://github.com/lambdalisue/deno-git-browse", 84 | } 85 | `; 86 | 87 | snapshot[`getHostingService 13`] = ` 88 | { 89 | result: "https://github.com/lambdalisue/deno-git-browse/tree/v0.1.0/bin", 90 | url: "https://github.com/lambdalisue/deno-git-browse", 91 | } 92 | `; 93 | 94 | snapshot[`getHostingService 14`] = ` 95 | { 96 | result: "https://github.com/lambdalisue/deno-git-browse/blob/v0.1.0/README.md", 97 | url: "https://github.com/lambdalisue/deno-git-browse", 98 | } 99 | `; 100 | 101 | snapshot[`getHostingService 15`] = ` 102 | { 103 | result: "https://github.com/lambdalisue/deno-git-browse/blob/v0.1.0/README.md?plain=1#L10", 104 | url: "https://github.com/lambdalisue/deno-git-browse", 105 | } 106 | `; 107 | 108 | snapshot[`getHostingService 16`] = ` 109 | { 110 | result: "https://github.com/lambdalisue/deno-git-browse/blob/v0.1.0/README.md?plain=1#L10-L20", 111 | url: "https://github.com/lambdalisue/deno-git-browse", 112 | } 113 | `; 114 | 115 | snapshot[`getHostingService 17`] = ` 116 | { 117 | result: "https://github.com/lambdalisue/deno-git-browse/blob/v0.1.0/bin/browse.ts", 118 | url: "https://github.com/lambdalisue/deno-git-browse", 119 | } 120 | `; 121 | 122 | snapshot[`getHostingService 18`] = ` 123 | { 124 | result: "https://github.com/lambdalisue/deno-git-browse/blob/v0.1.0/bin/browse.ts?plain=1#L10", 125 | url: "https://github.com/lambdalisue/deno-git-browse", 126 | } 127 | `; 128 | 129 | snapshot[`getHostingService 19`] = ` 130 | { 131 | result: "https://github.com/lambdalisue/deno-git-browse/blob/v0.1.0/bin/browse.ts?plain=1#L10-L20", 132 | url: "https://github.com/lambdalisue/deno-git-browse", 133 | } 134 | `; 135 | 136 | snapshot[`getHostingService 20`] = ` 137 | { 138 | result: "https://github.com/lambdalisue/deno-git-browse/pull/1", 139 | url: "https://github.com/lambdalisue/deno-git-browse", 140 | } 141 | `; 142 | 143 | snapshot[`getHostingService 21`] = ` 144 | { 145 | result: "https://gitlab.com/lambdalisue/deno-git-browse", 146 | url: "ssh://git@gitlab.com/lambdalisue/deno-git-browse", 147 | } 148 | `; 149 | 150 | snapshot[`getHostingService 22`] = ` 151 | { 152 | result: "https://gitlab.com/lambdalisue/deno-git-browse/-/commit/2120c87633f4059656e8aada8eebe62768892b46", 153 | url: "ssh://git@gitlab.com/lambdalisue/deno-git-browse", 154 | } 155 | `; 156 | 157 | snapshot[`getHostingService 23`] = ` 158 | { 159 | result: "https://gitlab.com/lambdalisue/deno-git-browse/-/tree/v0.1.0/bin", 160 | url: "ssh://git@gitlab.com/lambdalisue/deno-git-browse", 161 | } 162 | `; 163 | 164 | snapshot[`getHostingService 24`] = ` 165 | { 166 | result: "https://gitlab.com/lambdalisue/deno-git-browse/-/blob/v0.1.0/README.md", 167 | url: "ssh://git@gitlab.com/lambdalisue/deno-git-browse", 168 | } 169 | `; 170 | 171 | snapshot[`getHostingService 25`] = ` 172 | { 173 | result: "https://gitlab.com/lambdalisue/deno-git-browse/-/blob/v0.1.0/README.md?plain=1#L10", 174 | url: "ssh://git@gitlab.com/lambdalisue/deno-git-browse", 175 | } 176 | `; 177 | 178 | snapshot[`getHostingService 26`] = ` 179 | { 180 | result: "https://gitlab.com/lambdalisue/deno-git-browse/-/blob/v0.1.0/README.md?plain=1#L10-20", 181 | url: "ssh://git@gitlab.com/lambdalisue/deno-git-browse", 182 | } 183 | `; 184 | 185 | snapshot[`getHostingService 27`] = ` 186 | { 187 | result: "https://gitlab.com/lambdalisue/deno-git-browse/-/blob/v0.1.0/bin/browse.ts", 188 | url: "ssh://git@gitlab.com/lambdalisue/deno-git-browse", 189 | } 190 | `; 191 | 192 | snapshot[`getHostingService 28`] = ` 193 | { 194 | result: "https://gitlab.com/lambdalisue/deno-git-browse/-/blob/v0.1.0/bin/browse.ts?plain=1#L10", 195 | url: "ssh://git@gitlab.com/lambdalisue/deno-git-browse", 196 | } 197 | `; 198 | 199 | snapshot[`getHostingService 29`] = ` 200 | { 201 | result: "https://gitlab.com/lambdalisue/deno-git-browse/-/blob/v0.1.0/bin/browse.ts?plain=1#L10-20", 202 | url: "ssh://git@gitlab.com/lambdalisue/deno-git-browse", 203 | } 204 | `; 205 | 206 | snapshot[`getHostingService 30`] = ` 207 | { 208 | result: "https://gitlab.com/lambdalisue/deno-git-browse/-/merge_requests/1", 209 | url: "ssh://git@gitlab.com/lambdalisue/deno-git-browse", 210 | } 211 | `; 212 | 213 | snapshot[`getHostingService 31`] = ` 214 | { 215 | result: "https://gitlab.com/lambdalisue/deno-git-browse", 216 | url: "https://gitlab.com/lambdalisue/deno-git-browse", 217 | } 218 | `; 219 | 220 | snapshot[`getHostingService 32`] = ` 221 | { 222 | result: "https://gitlab.com/lambdalisue/deno-git-browse/-/commit/2120c87633f4059656e8aada8eebe62768892b46", 223 | url: "https://gitlab.com/lambdalisue/deno-git-browse", 224 | } 225 | `; 226 | 227 | snapshot[`getHostingService 33`] = ` 228 | { 229 | result: "https://gitlab.com/lambdalisue/deno-git-browse/-/tree/v0.1.0/bin", 230 | url: "https://gitlab.com/lambdalisue/deno-git-browse", 231 | } 232 | `; 233 | 234 | snapshot[`getHostingService 34`] = ` 235 | { 236 | result: "https://gitlab.com/lambdalisue/deno-git-browse/-/blob/v0.1.0/README.md", 237 | url: "https://gitlab.com/lambdalisue/deno-git-browse", 238 | } 239 | `; 240 | 241 | snapshot[`getHostingService 35`] = ` 242 | { 243 | result: "https://gitlab.com/lambdalisue/deno-git-browse/-/blob/v0.1.0/README.md?plain=1#L10", 244 | url: "https://gitlab.com/lambdalisue/deno-git-browse", 245 | } 246 | `; 247 | 248 | snapshot[`getHostingService 36`] = ` 249 | { 250 | result: "https://gitlab.com/lambdalisue/deno-git-browse/-/blob/v0.1.0/README.md?plain=1#L10-20", 251 | url: "https://gitlab.com/lambdalisue/deno-git-browse", 252 | } 253 | `; 254 | 255 | snapshot[`getHostingService 37`] = ` 256 | { 257 | result: "https://gitlab.com/lambdalisue/deno-git-browse/-/blob/v0.1.0/bin/browse.ts", 258 | url: "https://gitlab.com/lambdalisue/deno-git-browse", 259 | } 260 | `; 261 | 262 | snapshot[`getHostingService 38`] = ` 263 | { 264 | result: "https://gitlab.com/lambdalisue/deno-git-browse/-/blob/v0.1.0/bin/browse.ts?plain=1#L10", 265 | url: "https://gitlab.com/lambdalisue/deno-git-browse", 266 | } 267 | `; 268 | 269 | snapshot[`getHostingService 39`] = ` 270 | { 271 | result: "https://gitlab.com/lambdalisue/deno-git-browse/-/blob/v0.1.0/bin/browse.ts?plain=1#L10-20", 272 | url: "https://gitlab.com/lambdalisue/deno-git-browse", 273 | } 274 | `; 275 | 276 | snapshot[`getHostingService 40`] = ` 277 | { 278 | result: "https://gitlab.com/lambdalisue/deno-git-browse/-/merge_requests/1", 279 | url: "https://gitlab.com/lambdalisue/deno-git-browse", 280 | } 281 | `; 282 | 283 | snapshot[`getHostingService 41`] = ` 284 | { 285 | result: "https://bitbucket.org/lambdalisue/deno-git-browse", 286 | url: "ssh://git@bitbucket.org/lambdalisue/deno-git-browse", 287 | } 288 | `; 289 | 290 | snapshot[`getHostingService 42`] = ` 291 | { 292 | result: "https://bitbucket.org/lambdalisue/deno-git-browse/commits/2120c87633f4059656e8aada8eebe62768892b46", 293 | url: "ssh://git@bitbucket.org/lambdalisue/deno-git-browse", 294 | } 295 | `; 296 | 297 | snapshot[`getHostingService 43`] = ` 298 | { 299 | result: "https://bitbucket.org/lambdalisue/deno-git-browse/src/v0.1.0/bin", 300 | url: "ssh://git@bitbucket.org/lambdalisue/deno-git-browse", 301 | } 302 | `; 303 | 304 | snapshot[`getHostingService 44`] = ` 305 | { 306 | result: "https://bitbucket.org/lambdalisue/deno-git-browse/src/v0.1.0/README.md", 307 | url: "ssh://git@bitbucket.org/lambdalisue/deno-git-browse", 308 | } 309 | `; 310 | 311 | snapshot[`getHostingService 45`] = ` 312 | { 313 | result: "https://bitbucket.org/lambdalisue/deno-git-browse/annotate/v0.1.0/README.md#lines-10", 314 | url: "ssh://git@bitbucket.org/lambdalisue/deno-git-browse", 315 | } 316 | `; 317 | 318 | snapshot[`getHostingService 46`] = ` 319 | { 320 | result: "https://bitbucket.org/lambdalisue/deno-git-browse/annotate/v0.1.0/README.md#lines-10:20", 321 | url: "ssh://git@bitbucket.org/lambdalisue/deno-git-browse", 322 | } 323 | `; 324 | 325 | snapshot[`getHostingService 47`] = ` 326 | { 327 | result: "https://bitbucket.org/lambdalisue/deno-git-browse/src/v0.1.0/bin/browse.ts", 328 | url: "ssh://git@bitbucket.org/lambdalisue/deno-git-browse", 329 | } 330 | `; 331 | 332 | snapshot[`getHostingService 48`] = ` 333 | { 334 | result: "https://bitbucket.org/lambdalisue/deno-git-browse/src/v0.1.0/bin/browse.ts#lines-10", 335 | url: "ssh://git@bitbucket.org/lambdalisue/deno-git-browse", 336 | } 337 | `; 338 | 339 | snapshot[`getHostingService 49`] = ` 340 | { 341 | result: "https://bitbucket.org/lambdalisue/deno-git-browse/src/v0.1.0/bin/browse.ts#lines-10:20", 342 | url: "ssh://git@bitbucket.org/lambdalisue/deno-git-browse", 343 | } 344 | `; 345 | 346 | snapshot[`getHostingService 50`] = ` 347 | { 348 | result: "https://bitbucket.org/lambdalisue/deno-git-browse", 349 | url: "https://bitbucket.org/lambdalisue/deno-git-browse", 350 | } 351 | `; 352 | 353 | snapshot[`getHostingService 51`] = ` 354 | { 355 | result: "https://bitbucket.org/lambdalisue/deno-git-browse/commits/2120c87633f4059656e8aada8eebe62768892b46", 356 | url: "https://bitbucket.org/lambdalisue/deno-git-browse", 357 | } 358 | `; 359 | 360 | snapshot[`getHostingService 52`] = ` 361 | { 362 | result: "https://bitbucket.org/lambdalisue/deno-git-browse/src/v0.1.0/bin", 363 | url: "https://bitbucket.org/lambdalisue/deno-git-browse", 364 | } 365 | `; 366 | 367 | snapshot[`getHostingService 53`] = ` 368 | { 369 | result: "https://bitbucket.org/lambdalisue/deno-git-browse/src/v0.1.0/README.md", 370 | url: "https://bitbucket.org/lambdalisue/deno-git-browse", 371 | } 372 | `; 373 | 374 | snapshot[`getHostingService 54`] = ` 375 | { 376 | result: "https://bitbucket.org/lambdalisue/deno-git-browse/annotate/v0.1.0/README.md#lines-10", 377 | url: "https://bitbucket.org/lambdalisue/deno-git-browse", 378 | } 379 | `; 380 | 381 | snapshot[`getHostingService 55`] = ` 382 | { 383 | result: "https://bitbucket.org/lambdalisue/deno-git-browse/annotate/v0.1.0/README.md#lines-10:20", 384 | url: "https://bitbucket.org/lambdalisue/deno-git-browse", 385 | } 386 | `; 387 | 388 | snapshot[`getHostingService 56`] = ` 389 | { 390 | result: "https://bitbucket.org/lambdalisue/deno-git-browse/src/v0.1.0/bin/browse.ts", 391 | url: "https://bitbucket.org/lambdalisue/deno-git-browse", 392 | } 393 | `; 394 | 395 | snapshot[`getHostingService 57`] = ` 396 | { 397 | result: "https://bitbucket.org/lambdalisue/deno-git-browse/src/v0.1.0/bin/browse.ts#lines-10", 398 | url: "https://bitbucket.org/lambdalisue/deno-git-browse", 399 | } 400 | `; 401 | 402 | snapshot[`getHostingService 58`] = ` 403 | { 404 | result: "https://bitbucket.org/lambdalisue/deno-git-browse/src/v0.1.0/bin/browse.ts#lines-10:20", 405 | url: "https://bitbucket.org/lambdalisue/deno-git-browse", 406 | } 407 | `; 408 | 409 | snapshot[`getHostingService with alias 1`] = ` 410 | { 411 | result: "https://my-github.com/lambdalisue/deno-git-browse", 412 | url: "https://my-github.com/lambdalisue/deno-git-browse", 413 | } 414 | `; 415 | 416 | snapshot[`getHostingService with alias 2`] = ` 417 | { 418 | result: "https://my-github.com/lambdalisue/deno-git-browse/commit/2120c87633f4059656e8aada8eebe62768892b46", 419 | url: "https://my-github.com/lambdalisue/deno-git-browse", 420 | } 421 | `; 422 | 423 | snapshot[`getHostingService with alias 3`] = ` 424 | { 425 | result: "https://my-github.com/lambdalisue/deno-git-browse/tree/v0.1.0/bin", 426 | url: "https://my-github.com/lambdalisue/deno-git-browse", 427 | } 428 | `; 429 | 430 | snapshot[`getHostingService with alias 4`] = ` 431 | { 432 | result: "https://my-github.com/lambdalisue/deno-git-browse/blob/v0.1.0/README.md", 433 | url: "https://my-github.com/lambdalisue/deno-git-browse", 434 | } 435 | `; 436 | 437 | snapshot[`getHostingService with alias 5`] = ` 438 | { 439 | result: "https://my-github.com/lambdalisue/deno-git-browse/blob/v0.1.0/README.md?plain=1#L10", 440 | url: "https://my-github.com/lambdalisue/deno-git-browse", 441 | } 442 | `; 443 | 444 | snapshot[`getHostingService with alias 6`] = ` 445 | { 446 | result: "https://my-github.com/lambdalisue/deno-git-browse/blob/v0.1.0/README.md?plain=1#L10-L20", 447 | url: "https://my-github.com/lambdalisue/deno-git-browse", 448 | } 449 | `; 450 | 451 | snapshot[`getHostingService with alias 7`] = ` 452 | { 453 | result: "https://my-github.com/lambdalisue/deno-git-browse/pull/1", 454 | url: "https://my-github.com/lambdalisue/deno-git-browse", 455 | } 456 | `; 457 | --------------------------------------------------------------------------------