Compare commits

...

5 Commits

Author SHA1 Message Date
Claude Bot
f05725c05f fix: free stderr on success path and handle CRLF in tests
Address code review feedback:
- Free result.stderr on success path (sig == 0) before returning stdout
- Update test regexes to use \r?\n for Windows CRLF compatibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 02:56:20 +00:00
Claude Bot
522d716d79 fix: free stdout/stderr buffers on exec() error paths
Address code review feedback:
- Free result.stdout and result.stderr on all error paths in exec() to prevent
  memory leaks (check "repository not found" before freeing stderr)
- Update test assertions to use /\n(?:fatal:|error:|warning:)/ to verify git
  stderr appears on a subsequent line, not just matching bun's own error prefix

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 02:43:11 +00:00
Claude Bot
f4a1af6678 refactor: extract helper functions for git error handling
Address code review feedback:
- Extract captureGitStderr() to deduplicate stderr capture logic
- Extract logGitError() to centralize error formatting with git stderr
- Use toMatch(/fatal:|error:|warning:/) for more robust test assertions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 02:31:00 +00:00
Jarred Sumner
31cce17b6c Merge branch 'main' into claude/fix-git-clone-error-messages 2026-01-14 18:10:14 -08:00
Claude Bot
468b745b24 fix(install): show git stderr in git dependency error messages
When `bun install` fails to clone/fetch/checkout a git dependency, the error
message now includes the actual output from git. Previously, users only saw
generic messages like:

  error: "git clone" for "private-pkg" failed

Now they see the actual git error, e.g.:

  error: "git clone" for "private-pkg" failed:
  git@bitbucket.org: Permission denied (publickey).
  fatal: Could not read from remote repository.

This makes it much easier to diagnose authentication failures, network issues,
SSH key problems, and other git-related errors.

Fixes #2342

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 01:12:58 +00:00
3 changed files with 125 additions and 50 deletions

View File

@@ -2,6 +2,8 @@ threadlocal var final_path_buf: bun.PathBuffer = undefined;
threadlocal var ssh_path_buf: bun.PathBuffer = undefined;
threadlocal var folder_name_buf: bun.PathBuffer = undefined;
threadlocal var json_path_buf: bun.PathBuffer = undefined;
threadlocal var git_stderr_buf: [8192]u8 = undefined;
threadlocal var git_stderr_len: usize = 0;
const SloppyGlobalGitConfig = struct {
has_askpass: bool = false,
@@ -372,23 +374,79 @@ pub const Repository = extern struct {
});
switch (result.term) {
.Exited => |sig| if (sig == 0) return result.stdout else if (
// remote: The page could not be found <-- for non git
// remote: Repository not found. <-- for git
// remote: fatal repository '<url>' does not exist <-- for git
(strings.containsComptime(result.stderr, "remote:") and
strings.containsComptime(result.stderr, "not") and
strings.containsComptime(result.stderr, "found")) or
strings.containsComptime(result.stderr, "does not exist"))
{
return error.RepositoryNotFound;
.Exited => |sig| if (sig == 0) {
// Free stderr on success path (stdout is returned to caller)
allocator.free(result.stderr);
return result.stdout;
} else {
captureGitStderr(result.stderr);
// Check for "repository not found" before freeing stderr
const is_not_found =
// remote: The page could not be found <-- for non git
// remote: Repository not found. <-- for git
// remote: fatal repository '<url>' does not exist <-- for git
(strings.containsComptime(result.stderr, "remote:") and
strings.containsComptime(result.stderr, "not") and
strings.containsComptime(result.stderr, "found")) or
strings.containsComptime(result.stderr, "does not exist");
// Free allocated buffers on error path
allocator.free(result.stdout);
allocator.free(result.stderr);
if (is_not_found) {
return error.RepositoryNotFound;
}
},
else => {
captureGitStderr(result.stderr);
// Free allocated buffers on error path
allocator.free(result.stdout);
allocator.free(result.stderr);
},
else => {},
}
return error.InstallFailed;
}
fn captureGitStderr(stderr: []const u8) void {
const stderr_trimmed = strings.trim(stderr, " \t\r\n");
const copy_len = @min(stderr_trimmed.len, git_stderr_buf.len);
@memcpy(git_stderr_buf[0..copy_len], stderr_trimmed[0..copy_len]);
git_stderr_len = copy_len;
}
fn getLastGitStderr() []const u8 {
return git_stderr_buf[0..git_stderr_len];
}
fn logGitError(
log: *logger.Log,
allocator: std.mem.Allocator,
comptime base_fmt: []const u8,
args: anytype,
) void {
const git_stderr = getLastGitStderr();
if (git_stderr.len > 0) {
log.addErrorFmt(
null,
logger.Loc.Empty,
allocator,
base_fmt ++ ":\n{s}",
args ++ .{git_stderr},
) catch unreachable;
} else {
log.addErrorFmt(
null,
logger.Loc.Empty,
allocator,
base_fmt,
args,
) catch unreachable;
}
}
pub fn trySSH(url: string) ?string {
// Do not cast explicit http(s) URLs to SSH
if (strings.hasPrefixComptime(url, "http")) {
@@ -512,13 +570,7 @@ pub const Repository = extern struct {
env,
&[_]string{ "git", "-C", path, "fetch", "--quiet" },
) catch |err| {
log.addErrorFmt(
null,
logger.Loc.Empty,
allocator,
"\"git fetch\" for \"{s}\" failed",
.{name},
) catch unreachable;
logGitError(log, allocator, "\"git fetch\" for \"{s}\" failed", .{name});
return err;
};
break :fetch dir;
@@ -538,13 +590,7 @@ pub const Repository = extern struct {
target,
}) catch |err| {
if (err == error.RepositoryNotFound or attempt > 1) {
log.addErrorFmt(
null,
logger.Loc.Empty,
allocator,
"\"git clone\" for \"{s}\" failed",
.{name},
) catch unreachable;
logGitError(log, allocator, "\"git clone\" for \"{s}\" failed", .{name});
}
return err;
};
@@ -576,13 +622,7 @@ pub const Repository = extern struct {
else
&[_]string{ "git", "-C", path, "log", "--format=%H", "-1" },
) catch |err| {
log.addErrorFmt(
null,
logger.Loc.Empty,
allocator,
"no commit matching \"{s}\" found for \"{s}\" (but repository exists)",
.{ committish, name },
) catch unreachable;
logGitError(log, allocator, "no commit matching \"{s}\" found for \"{s}\" (but repository exists)", .{ committish, name });
return err;
}, " \t\r\n");
}
@@ -615,26 +655,14 @@ pub const Repository = extern struct {
try bun.getFdPath(.fromStdDir(repo_dir), &final_path_buf),
target,
}) catch |err| {
log.addErrorFmt(
null,
logger.Loc.Empty,
allocator,
"\"git clone\" for \"{s}\" failed",
.{name},
) catch unreachable;
logGitError(log, allocator, "\"git clone\" for \"{s}\" failed", .{name});
return err;
};
const folder = Path.joinAbsString(PackageManager.get().cache_directory_path, &.{folder_name}, .auto);
_ = exec(allocator, env, &[_]string{ "git", "-C", folder, "checkout", "--quiet", resolved }) catch |err| {
log.addErrorFmt(
null,
logger.Loc.Empty,
allocator,
"\"git checkout\" for \"{s}\" failed",
.{name},
) catch unreachable;
logGitError(log, allocator, "\"git checkout\" for \"{s}\" failed", .{name});
return err;
};
var dir = try bun.openDir(cache_dir, folder_name);

View File

@@ -5084,7 +5084,10 @@ describe.concurrent("bun-install", () => {
env: { ...env, "GIT_ASKPASS": "echo" },
});
const err = await stderr.text();
expect(err.split(/\r?\n/)).toContain('error: "git clone" for "private-install" failed');
// The error message now includes git stderr, so check for the base message
expect(err).toContain('error: "git clone" for "private-install" failed');
// Verify git stderr is appended on a subsequent line (not just bun's own error: prefix)
expect(err).toMatch(/\r?\n(?:fatal:|error:|warning:)/);
const out = await stdout.text();
expect(out).toEqual(expect.stringContaining("bun install v1."));
expect(await exited).toBe(1);
@@ -5122,9 +5125,10 @@ describe.concurrent("bun-install", () => {
env,
});
const err = await stderr.text();
expect(err.split(/\r?\n/)).toContain(
'error: no commit matching "404-no_such_tag" found for "uglify" (but repository exists)',
);
// The error message now includes git stderr, so check for the base message
expect(err).toContain('error: no commit matching "404-no_such_tag" found for "uglify" (but repository exists)');
// Verify git stderr is appended on a subsequent line (not just bun's own error: prefix)
expect(err).toMatch(/\r?\n(?:fatal:|error:|warning:)/);
const out = await stdout.text();
expect(out).toEqual(expect.stringContaining("bun install v1."));
expect(await exited).toBe(1);

View File

@@ -0,0 +1,43 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
// Regression test for https://github.com/oven-sh/bun/issues/2342
// Git clone failures should include the actual git error output, not just a generic message
test("git clone failure should show actual git error output", async () => {
using dir = tempDir("issue-2342", {
"package.json": JSON.stringify({
name: "test-git-error",
version: "1.0.0",
dependencies: {
// Use a non-existent SSH URL to trigger a git clone failure with stderr
"private-pkg": "git+ssh://git@bitbucket.org/nonexistent/nonexistent-repo-12345.git",
},
}),
});
await using proc = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: String(dir),
env: { ...bunEnv, GIT_ASKPASS: "echo" },
stdout: "pipe",
stderr: "pipe",
stdin: "ignore",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// The error message should contain the git clone failure with the actual git error details
// Before the fix, this would only show: error: "git clone" for "private-pkg" failed
// After the fix, it shows the actual git error like:
// error: "git clone" for "private-pkg" failed:
// git@bitbucket.org: Permission denied (publickey).
// fatal: Could not read from remote repository.
expect(stderr).toContain('"git clone" for "private-pkg" failed');
// The error should include the actual git stderr output, which contains "fatal:" from git
// This is distinct from bun's own "error:" messages - we want to see the git-specific output
expect(stderr).toContain("fatal:");
// The exit code should be non-zero
expect(exitCode).not.toBe(0);
});