Skip to content

Commit a690792

Browse files
committed
fix: add proper error checking for git commands
- Add error handling to git utility functions (getCurrentBranch, getRepoDir) - Add error checking to all git checkout commands in vcs.ts - Handle detached HEAD state gracefully in getCurrentBranch - Add comprehensive tests for git error scenarios - Tests verify errors are thrown with descriptive messages - Tests verify detached HEAD returns null instead of throwing Fixes #62
1 parent fd3902b commit a690792

4 files changed

Lines changed: 325 additions & 9 deletions

File tree

src/utils/git.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,35 @@ import { basename } from "@std/path"
33
export async function getCurrentBranch(): Promise<string | null> {
44
const process = new Deno.Command("git", {
55
args: ["symbolic-ref", "--short", "HEAD"],
6+
stderr: "piped",
67
})
7-
const { stdout } = await process.output()
8+
const { success, stdout, stderr } = await process.output()
9+
10+
if (!success) {
11+
const errorMsg = new TextDecoder().decode(stderr).trim()
12+
// Handle detached HEAD state gracefully - this is not necessarily an error
13+
if (errorMsg.includes("not a symbolic ref")) {
14+
return null
15+
}
16+
throw new Error(`Failed to get current branch: ${errorMsg}`)
17+
}
18+
819
const branch = new TextDecoder().decode(stdout).trim()
920
return branch || null
1021
}
1122

1223
export async function getRepoDir(): Promise<string> {
1324
const process = new Deno.Command("git", {
1425
args: ["rev-parse", "--show-toplevel"],
26+
stderr: "piped",
1527
})
16-
const { stdout } = await process.output()
28+
const { success, stdout, stderr } = await process.output()
29+
30+
if (!success) {
31+
const errorMsg = new TextDecoder().decode(stderr).trim()
32+
throw new Error(`Failed to get repository directory: ${errorMsg}`)
33+
}
34+
1735
const fullPath = new TextDecoder().decode(stdout).trim()
1836
return basename(fullPath)
1937
}

src/utils/vcs.ts

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,20 @@ export function getNoIssueFoundMessage(): string {
3434
* Checks if a git branch exists
3535
*/
3636
async function gitBranchExists(branchName: string): Promise<boolean> {
37-
const process = await new Deno.Command("git", {
38-
args: ["rev-parse", "--verify", branchName],
39-
}).output()
37+
try {
38+
const process = await new Deno.Command("git", {
39+
args: ["rev-parse", "--verify", branchName],
40+
stderr: "piped",
41+
}).output()
4042

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

4453
/**
@@ -96,8 +105,15 @@ export async function startVcsWork(
96105
if (answer === "switch") {
97106
const process = new Deno.Command("git", {
98107
args: ["checkout", branchName],
108+
stderr: "piped",
99109
})
100-
await process.output()
110+
const { success, stderr } = await process.output()
111+
if (!success) {
112+
const errorMsg = new TextDecoder().decode(stderr).trim()
113+
throw new Error(
114+
`Failed to switch to branch '${branchName}': ${errorMsg}`,
115+
)
116+
}
101117
console.log(`✓ Switched to '${branchName}'`)
102118
} else {
103119
// Find next available suffix
@@ -110,16 +126,30 @@ export async function startVcsWork(
110126

111127
const process = new Deno.Command("git", {
112128
args: ["checkout", "-b", newBranch, gitSourceRef || "HEAD"],
129+
stderr: "piped",
113130
})
114-
await process.output()
131+
const { success, stderr } = await process.output()
132+
if (!success) {
133+
const errorMsg = new TextDecoder().decode(stderr).trim()
134+
throw new Error(
135+
`Failed to create branch '${newBranch}': ${errorMsg}`,
136+
)
137+
}
115138
console.log(`✓ Created and switched to branch '${newBranch}'`)
116139
}
117140
} else {
118141
// Create and checkout the branch
119142
const process = new Deno.Command("git", {
120143
args: ["checkout", "-b", branchName, gitSourceRef || "HEAD"],
144+
stderr: "piped",
121145
})
122-
await process.output()
146+
const { success, stderr } = await process.output()
147+
if (!success) {
148+
const errorMsg = new TextDecoder().decode(stderr).trim()
149+
throw new Error(
150+
`Failed to create branch '${branchName}': ${errorMsg}`,
151+
)
152+
}
123153
console.log(`✓ Created and switched to branch '${branchName}'`)
124154
}
125155
break

test/utils/git.test.ts

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

test/utils/vcs.test.ts

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

0 commit comments

Comments
 (0)