Skip to content

Commit 1d8641a

Browse files
committed
fix: improve git command error handling with CliError
- Use CliError instead of generic Error for all git command failures - Add proper error checking to getCurrentBranch() and getRepoDir() - Improve error handling in startVcsWork() git operations - Add comprehensive tests for git error handling - Ensure consistent error reporting across git utilities Fixes #62
1 parent fd3902b commit 1d8641a

4 files changed

Lines changed: 330 additions & 9 deletions

File tree

src/utils/git.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,38 @@
11
import { basename } from "@std/path"
2+
import { CliError } from "./errors.ts"
23

34
export async function getCurrentBranch(): Promise<string | null> {
45
const process = new Deno.Command("git", {
56
args: ["symbolic-ref", "--short", "HEAD"],
7+
stderr: "piped",
68
})
7-
const { stdout } = await process.output()
9+
const { success, stdout, stderr } = await process.output()
10+
11+
if (!success) {
12+
const errorMsg = new TextDecoder().decode(stderr).trim()
13+
// Handle detached HEAD state gracefully - this is not necessarily an error
14+
if (errorMsg.includes("not a symbolic ref")) {
15+
return null
16+
}
17+
throw new CliError(`Failed to get current branch: ${errorMsg}`)
18+
}
19+
820
const branch = new TextDecoder().decode(stdout).trim()
921
return branch || null
1022
}
1123

1224
export async function getRepoDir(): Promise<string> {
1325
const process = new Deno.Command("git", {
1426
args: ["rev-parse", "--show-toplevel"],
27+
stderr: "piped",
1528
})
16-
const { stdout } = await process.output()
29+
const { success, stdout, stderr } = await process.output()
30+
31+
if (!success) {
32+
const errorMsg = new TextDecoder().decode(stderr).trim()
33+
throw new CliError(`Failed to get repository directory: ${errorMsg}`)
34+
}
35+
1736
const fullPath = new TextDecoder().decode(stdout).trim()
1837
return basename(fullPath)
1938
}

src/utils/vcs.ts

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from "./jj.ts"
99
import { getCurrentBranch } from "./git.ts"
1010
import { Select } from "@cliffy/prompt"
11+
import { CliError } from "./errors.ts"
1112

1213
export type VcsType = "git" | "jj"
1314

@@ -34,11 +35,21 @@ export function getNoIssueFoundMessage(): string {
3435
* Checks if a git branch exists
3536
*/
3637
async function gitBranchExists(branchName: string): Promise<boolean> {
37-
const process = await new Deno.Command("git", {
38-
args: ["rev-parse", "--verify", branchName],
39-
}).output()
38+
try {
39+
const process = await new Deno.Command("git", {
40+
args: ["rev-parse", "--verify", branchName],
41+
stderr: "piped",
42+
}).output()
4043

41-
return process.success
44+
return process.success
45+
} catch (error) {
46+
throw new CliError(
47+
`Failed to check if branch exists: ${
48+
error instanceof Error ? error.message : String(error)
49+
}`,
50+
{ cause: error },
51+
)
52+
}
4253
}
4354

4455
/**
@@ -96,8 +107,15 @@ export async function startVcsWork(
96107
if (answer === "switch") {
97108
const process = new Deno.Command("git", {
98109
args: ["checkout", branchName],
110+
stderr: "piped",
99111
})
100-
await process.output()
112+
const { success, stderr } = await process.output()
113+
if (!success) {
114+
const errorMsg = new TextDecoder().decode(stderr).trim()
115+
throw new CliError(
116+
`Failed to switch to branch '${branchName}': ${errorMsg}`,
117+
)
118+
}
101119
console.log(`✓ Switched to '${branchName}'`)
102120
} else {
103121
// Find next available suffix
@@ -110,16 +128,30 @@ export async function startVcsWork(
110128

111129
const process = new Deno.Command("git", {
112130
args: ["checkout", "-b", newBranch, gitSourceRef || "HEAD"],
131+
stderr: "piped",
113132
})
114-
await process.output()
133+
const { success, stderr } = await process.output()
134+
if (!success) {
135+
const errorMsg = new TextDecoder().decode(stderr).trim()
136+
throw new CliError(
137+
`Failed to create branch '${newBranch}': ${errorMsg}`,
138+
)
139+
}
115140
console.log(`✓ Created and switched to branch '${newBranch}'`)
116141
}
117142
} else {
118143
// Create and checkout the branch
119144
const process = new Deno.Command("git", {
120145
args: ["checkout", "-b", branchName, gitSourceRef || "HEAD"],
146+
stderr: "piped",
121147
})
122-
await process.output()
148+
const { success, stderr } = await process.output()
149+
if (!success) {
150+
const errorMsg = new TextDecoder().decode(stderr).trim()
151+
throw new CliError(
152+
`Failed to create branch '${branchName}': ${errorMsg}`,
153+
)
154+
}
123155
console.log(`✓ Created and switched to branch '${branchName}'`)
124156
}
125157
break

test/utils/git.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { assertEquals, assertRejects } from "@std/assert"
2+
import { getCurrentBranch, getRepoDir } from "../../src/utils/git.ts"
3+
import { CliError } from "../../src/utils/errors.ts"
4+
5+
Deno.test("getCurrentBranch - handles errors when not in a git repository", async () => {
6+
// Create a temporary directory that's not a git repo
7+
const tempDir = await Deno.makeTempDir()
8+
const originalCwd = Deno.cwd()
9+
10+
try {
11+
Deno.chdir(tempDir)
12+
await assertRejects(
13+
async () => await getCurrentBranch(),
14+
CliError,
15+
"Failed to get current branch",
16+
)
17+
} finally {
18+
Deno.chdir(originalCwd)
19+
await Deno.remove(tempDir, { recursive: true })
20+
}
21+
})
22+
23+
Deno.test("getRepoDir - handles errors when not in a git repository", async () => {
24+
// Create a temporary directory that's not a git repo
25+
const tempDir = await Deno.makeTempDir()
26+
const originalCwd = Deno.cwd()
27+
28+
try {
29+
Deno.chdir(tempDir)
30+
await assertRejects(
31+
async () => await getRepoDir(),
32+
CliError,
33+
"Failed to get repository directory",
34+
)
35+
} finally {
36+
Deno.chdir(originalCwd)
37+
await Deno.remove(tempDir, { recursive: true })
38+
}
39+
})
40+
41+
Deno.test("getCurrentBranch - returns null for detached HEAD", async () => {
42+
// Create a temporary git repository
43+
const tempDir = await Deno.makeTempDir()
44+
const originalCwd = Deno.cwd()
45+
46+
try {
47+
Deno.chdir(tempDir)
48+
49+
// Initialize git repo
50+
await new Deno.Command("git", { args: ["init"] }).output()
51+
await new Deno.Command("git", {
52+
args: ["config", "user.email", "test@example.com"],
53+
}).output()
54+
await new Deno.Command("git", {
55+
args: ["config", "user.name", "Test User"],
56+
}).output()
57+
58+
// Create a commit
59+
await Deno.writeTextFile("test.txt", "test")
60+
await new Deno.Command("git", { args: ["add", "test.txt"] }).output()
61+
await new Deno.Command("git", {
62+
args: ["commit", "-m", "initial commit"],
63+
}).output()
64+
65+
// Get the commit hash
66+
const { stdout } = await new Deno.Command("git", {
67+
args: ["rev-parse", "HEAD"],
68+
}).output()
69+
const commitHash = new TextDecoder().decode(stdout).trim()
70+
71+
// Checkout the commit to create detached HEAD
72+
await new Deno.Command("git", {
73+
args: ["checkout", commitHash],
74+
}).output()
75+
76+
// getCurrentBranch should return null for detached HEAD
77+
const branch = await getCurrentBranch()
78+
assertEquals(branch, null)
79+
} finally {
80+
Deno.chdir(originalCwd)
81+
await Deno.remove(tempDir, { recursive: true })
82+
}
83+
})

test/utils/vcs.test.ts

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { assertEquals, assertRejects } from "@std/assert"
2+
import { getCurrentIssueFromVcs, startVcsWork } from "../../src/utils/vcs.ts"
3+
import { CliError } from "../../src/utils/errors.ts"
4+
5+
Deno.test("getCurrentIssueFromVcs - handles git errors gracefully", async () => {
6+
// Create a temporary directory that's not a git repo
7+
const tempDir = await Deno.makeTempDir()
8+
const originalCwd = Deno.cwd()
9+
const originalVcs = Deno.env.get("LINEAR_VCS")
10+
11+
try {
12+
// Explicitly set VCS to git for this test
13+
Deno.env.set("LINEAR_VCS", "git")
14+
Deno.chdir(tempDir)
15+
await assertRejects(
16+
async () => await getCurrentIssueFromVcs(),
17+
CliError,
18+
"Failed to get current branch",
19+
)
20+
} finally {
21+
Deno.chdir(originalCwd)
22+
if (originalVcs !== undefined) {
23+
Deno.env.set("LINEAR_VCS", originalVcs)
24+
} else {
25+
Deno.env.delete("LINEAR_VCS")
26+
}
27+
await Deno.remove(tempDir, { recursive: true })
28+
}
29+
})
30+
31+
Deno.test("getCurrentIssueFromVcs - extracts issue ID from git branch", async () => {
32+
// Create a temporary git repository
33+
const tempDir = await Deno.makeTempDir()
34+
const originalCwd = Deno.cwd()
35+
const originalVcs = Deno.env.get("LINEAR_VCS")
36+
37+
try {
38+
// Explicitly set VCS to git for this test
39+
Deno.env.set("LINEAR_VCS", "git")
40+
Deno.chdir(tempDir)
41+
42+
// Initialize git repo
43+
await new Deno.Command("git", { args: ["init"] }).output()
44+
await new Deno.Command("git", {
45+
args: ["config", "user.email", "test@example.com"],
46+
}).output()
47+
await new Deno.Command("git", {
48+
args: ["config", "user.name", "Test User"],
49+
}).output()
50+
51+
// Create initial commit
52+
await Deno.writeTextFile("test.txt", "test")
53+
await new Deno.Command("git", { args: ["add", "test.txt"] }).output()
54+
await new Deno.Command("git", {
55+
args: ["commit", "-m", "initial commit"],
56+
}).output()
57+
58+
// Create a branch with an issue ID
59+
await new Deno.Command("git", {
60+
args: ["checkout", "-b", "feature/ABC-123-test-feature"],
61+
}).output()
62+
63+
const issueId = await getCurrentIssueFromVcs()
64+
assertEquals(issueId, "ABC-123")
65+
} finally {
66+
Deno.chdir(originalCwd)
67+
if (originalVcs !== undefined) {
68+
Deno.env.set("LINEAR_VCS", originalVcs)
69+
} else {
70+
Deno.env.delete("LINEAR_VCS")
71+
}
72+
await Deno.remove(tempDir, { recursive: true })
73+
}
74+
})
75+
76+
Deno.test("getCurrentIssueFromVcs - returns null for branch without issue ID", async () => {
77+
// Create a temporary git repository
78+
const tempDir = await Deno.makeTempDir()
79+
const originalCwd = Deno.cwd()
80+
const originalVcs = Deno.env.get("LINEAR_VCS")
81+
82+
try {
83+
// Explicitly set VCS to git for this test
84+
Deno.env.set("LINEAR_VCS", "git")
85+
Deno.chdir(tempDir)
86+
87+
// Initialize git repo
88+
await new Deno.Command("git", { args: ["init"] }).output()
89+
await new Deno.Command("git", {
90+
args: ["config", "user.email", "test@example.com"],
91+
}).output()
92+
await new Deno.Command("git", {
93+
args: ["config", "user.name", "Test User"],
94+
}).output()
95+
96+
// Create initial commit
97+
await Deno.writeTextFile("test.txt", "test")
98+
await new Deno.Command("git", { args: ["add", "test.txt"] }).output()
99+
await new Deno.Command("git", {
100+
args: ["commit", "-m", "initial commit"],
101+
}).output()
102+
103+
// Create a branch without an issue ID
104+
await new Deno.Command("git", {
105+
args: ["checkout", "-b", "main"],
106+
}).output()
107+
108+
const issueId = await getCurrentIssueFromVcs()
109+
assertEquals(issueId, null)
110+
} finally {
111+
Deno.chdir(originalCwd)
112+
if (originalVcs !== undefined) {
113+
Deno.env.set("LINEAR_VCS", originalVcs)
114+
} else {
115+
Deno.env.delete("LINEAR_VCS")
116+
}
117+
await Deno.remove(tempDir, { recursive: true })
118+
}
119+
})
120+
121+
Deno.test("startVcsWork - propagates git checkout errors when not in a git repo", async () => {
122+
const tempDir = await Deno.makeTempDir()
123+
const originalCwd = Deno.cwd()
124+
const originalVcs = Deno.env.get("LINEAR_VCS")
125+
126+
try {
127+
Deno.env.set("LINEAR_VCS", "git")
128+
Deno.chdir(tempDir)
129+
130+
await assertRejects(
131+
async () => await startVcsWork("ABC-123", "feature/ABC-123-test"),
132+
CliError,
133+
"Failed to create branch",
134+
)
135+
} finally {
136+
Deno.chdir(originalCwd)
137+
if (originalVcs !== undefined) {
138+
Deno.env.set("LINEAR_VCS", originalVcs)
139+
} else {
140+
Deno.env.delete("LINEAR_VCS")
141+
}
142+
await Deno.remove(tempDir, { recursive: true })
143+
}
144+
})
145+
146+
Deno.test("startVcsWork - propagates git checkout errors when source ref doesn't exist", async () => {
147+
const tempDir = await Deno.makeTempDir()
148+
const originalCwd = Deno.cwd()
149+
const originalVcs = Deno.env.get("LINEAR_VCS")
150+
151+
try {
152+
Deno.env.set("LINEAR_VCS", "git")
153+
Deno.chdir(tempDir)
154+
155+
// Initialize git repo
156+
await new Deno.Command("git", { args: ["init"] }).output()
157+
await new Deno.Command("git", {
158+
args: ["config", "user.email", "test@example.com"],
159+
}).output()
160+
await new Deno.Command("git", {
161+
args: ["config", "user.name", "Test User"],
162+
}).output()
163+
164+
// Create initial commit
165+
await Deno.writeTextFile("test.txt", "test")
166+
await new Deno.Command("git", { args: ["add", "test.txt"] }).output()
167+
await new Deno.Command("git", {
168+
args: ["commit", "-m", "initial commit"],
169+
}).output()
170+
171+
// Try to create a branch from a non-existent ref
172+
await assertRejects(
173+
async () =>
174+
await startVcsWork("ABC-123", "feature/ABC-123-test", "nonexistent"),
175+
CliError,
176+
"Failed to create branch",
177+
)
178+
} finally {
179+
Deno.chdir(originalCwd)
180+
if (originalVcs !== undefined) {
181+
Deno.env.set("LINEAR_VCS", originalVcs)
182+
} else {
183+
Deno.env.delete("LINEAR_VCS")
184+
}
185+
await Deno.remove(tempDir, { recursive: true })
186+
}
187+
})

0 commit comments

Comments
 (0)