Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/commands/issue/issue-mine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 <key> to specify a team, or run `linear config` to link this repository to a team."
: "Use --team <key> to specify a team.",
},
)
}

Expand Down
21 changes: 21 additions & 0 deletions src/utils/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,27 @@ export async function getRepoDir(): Promise<string> {
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<boolean> {
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<boolean> {
try {
const process = new Deno.Command("git", {
Expand Down
83 changes: 83 additions & 0 deletions test/commands/issue/issue-mine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <key> 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 <key> 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
Expand Down
38 changes: 37 additions & 1 deletion test/utils/git.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -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 })
}
})
Loading