diff --git a/src/commands/issue/issue-mine.ts b/src/commands/issue/issue-mine.ts index b8543887..8429cbf5 100644 --- a/src/commands/issue/issue-mine.ts +++ b/src/commands/issue/issue-mine.ts @@ -2,6 +2,7 @@ import { Command, EnumType } from "@cliffy/command" import { unicodeWidth } from "@std/cli" import { rgb24 } from "@std/fmt/colors" import { resolveIssueSort } from "../../config.ts" +import { isInsideGitRepo } from "../../utils/git.ts" import { colorCycleShort, formatCycleShort, @@ -182,7 +183,12 @@ export const mineCommand = new Command() const teamKey = team || getTeamKey() if (!teamKey) { throw new ValidationError( - "Could not determine team key from directory name or team flag", + "No default team configured and no team scope provided", + { + suggestion: (await isInsideGitRepo()) + ? "Use --team to specify a team, or run `linear config` to link this repository to a team." + : "Use --team to specify a team.", + }, ) } diff --git a/src/utils/git.ts b/src/utils/git.ts index d08f27d5..619c6a32 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -37,6 +37,27 @@ export async function getRepoDir(): Promise { return basename(fullPath) } +/** + * Best-effort check for whether the current directory is inside a git work + * tree. Any failure — git not installed, not a repository, dubious + * ownership, a spawn error — counts as false: callers use this only for + * optional guidance, which must never turn into a git crash. + */ +export async function isInsideGitRepo(): Promise { + try { + const { success, stdout } = await new Deno.Command("git", { + args: ["rev-parse", "--is-inside-work-tree"], + stdout: "piped", + stderr: "null", + }).output() + // Prints "false" (exit 0) inside a .git dir or bare repo, so the exit + // code alone is not enough. + return success && new TextDecoder().decode(stdout).trim() === "true" + } catch { + return false + } +} + export async function branchExists(branch: string): Promise { try { const process = new Deno.Command("git", { diff --git a/test/commands/issue/issue-mine.test.ts b/test/commands/issue/issue-mine.test.ts index 2bac7edd..12b9e905 100644 --- a/test/commands/issue/issue-mine.test.ts +++ b/test/commands/issue/issue-mine.test.ts @@ -402,6 +402,89 @@ Deno.test("Issue Mine Command - Defaults To Priority Sort When Nothing Is Config } }) +// Runs `issue mine` as a subprocess from a temp cwd with a clean env, so no +// config file or env var supplies a team. Returns the exit code and stderr. +// The no-team error is untestable in-process from the repo root because the +// repo's own .linear.toml sets team_id. +async function runMineWithoutTeam( + tempDir: string, +): Promise<{ code: number; errorOutput: string }> { + const mainPath = fromFileUrl( + new URL("../../../src/main.ts", import.meta.url), + ) + const denoJsonPath = fromFileUrl( + new URL("../../../deno.json", import.meta.url), + ) + const homeDir = Deno.env.get("HOME") + const denoDir = Deno.env.get("DENO_DIR") ?? + (homeDir == null ? undefined : join(homeDir, ".cache", "deno")) + const command = new Deno.Command(Deno.execPath(), { + args: [ + "run", + "--allow-all", + "--quiet", + `--config=${denoJsonPath}`, + mainPath, + "issue", + "mine", + ], + cwd: tempDir, + clearEnv: true, + env: { + PATH: Deno.env.get("PATH") ?? "", + HOME: tempDir, + XDG_CONFIG_HOME: join(tempDir, ".config"), + ...(denoDir == null ? {} : { DENO_DIR: denoDir }), + ...(Deno.build.os === "windows" + ? { SystemRoot: Deno.env.get("SystemRoot") ?? "" } + : {}), + LINEAR_API_KEY: "Bearer test-token", + NO_COLOR: "true", + }, + stdout: "piped", + stderr: "piped", + }) + + const { code, stderr } = await command.output() + return { code, errorOutput: new TextDecoder().decode(stderr) } +} + +Deno.test("Issue Mine Command - No Team Error Outside A Repo", async () => { + const tempDir = await Deno.makeTempDir() + + try { + const { code, errorOutput } = await runMineWithoutTeam(tempDir) + + assertEquals(code, 1, `stderr: ${errorOutput}`) + assertEquals( + errorOutput.trim(), + "✗ Failed to list issues: No default team configured and no team scope provided\n" + + " Use --team to specify a team.", + ) + } finally { + await Deno.remove(tempDir, { recursive: true }) + } +}) + +Deno.test("Issue Mine Command - No Team Error Inside A Repo Suggests linear config", async () => { + const tempDir = await Deno.makeTempDir() + + try { + await new Deno.Command("git", { args: ["init"], cwd: tempDir }).output() + + const { code, errorOutput } = await runMineWithoutTeam(tempDir) + + assertEquals(code, 1, `stderr: ${errorOutput}`) + assertEquals( + errorOutput.trim(), + "✗ Failed to list issues: No default team configured and no team scope provided\n" + + " Use --team to specify a team, or run `linear config` to link this repository to a team.", + ) + } finally { + await Deno.remove(tempDir, { recursive: true }) + } +}) + Deno.test("Issue Mine Command - Shows Blocked Indicator", async () => { const fixedNow = new Date("2026-03-30T10:00:00.000Z") const RealDate = Date diff --git a/test/utils/git.test.ts b/test/utils/git.test.ts index 6092284d..3d6bfa43 100644 --- a/test/utils/git.test.ts +++ b/test/utils/git.test.ts @@ -1,5 +1,9 @@ import { assertEquals, assertRejects } from "@std/assert" -import { getCurrentBranch, getRepoDir } from "../../src/utils/git.ts" +import { + getCurrentBranch, + getRepoDir, + isInsideGitRepo, +} from "../../src/utils/git.ts" import { CliError } from "../../src/utils/errors.ts" Deno.test("getCurrentBranch - handles errors when not in a git repository", async () => { @@ -81,3 +85,35 @@ Deno.test("getCurrentBranch - returns null for detached HEAD", async () => { await Deno.remove(tempDir, { recursive: true }) } }) + +Deno.test("isInsideGitRepo - false in a non-repository directory", async () => { + const tempDir = await Deno.makeTempDir() + const originalCwd = Deno.cwd() + + try { + Deno.chdir(tempDir) + assertEquals(await isInsideGitRepo(), false) + } finally { + Deno.chdir(originalCwd) + await Deno.remove(tempDir, { recursive: true }) + } +}) + +Deno.test("isInsideGitRepo - true inside a git repository, including nested directories", async () => { + const tempDir = await Deno.makeTempDir() + const originalCwd = Deno.cwd() + + try { + Deno.chdir(tempDir) + await new Deno.Command("git", { args: ["init"] }).output() + assertEquals(await isInsideGitRepo(), true) + + const nested = `${tempDir}/nested/dir` + await Deno.mkdir(nested, { recursive: true }) + Deno.chdir(nested) + assertEquals(await isInsideGitRepo(), true) + } finally { + Deno.chdir(originalCwd) + await Deno.remove(tempDir, { recursive: true }) + } +})