Skip to content

Commit 8d4c5c6

Browse files
Copilotalirezanet
andauthored
fix: handle file paths with spaces in git commands (#156)
* Initial plan * chore: initial investigation of path with spaces issue Co-authored-by: alirezanet <7004080+alirezanet@users.noreply.github.com> * fix: handle file paths with spaces in git commands - Add array-based overloads to ICliWrap interface - Implement array-based ExecAsync and ExecBufferedAsync in HuskyCliWrap - Update IGit interface with array-based overloads - Update Git service to support array arguments - Fix StagedTask.ReStageFiles to pass file paths as separate arguments - Remove platform-specific quoting logic (now handled by CliWrap) - Fix hash-object and update-index commands to use array arguments Co-authored-by: alirezanet <7004080+alirezanet@users.noreply.github.com> * fix: remove quotes from verbose logging to match actual execution Update logging in ReStageFiles to display file paths without quotes, matching how they are actually passed to git commands. Co-authored-by: alirezanet <7004080+alirezanet@users.noreply.github.com> * fix: remove test tasks from task-runner.json to avoid cross-platform issues Removed the Windows-specific test tasks ('echo staged files' and 'root-dir' variable) that were causing failures on Linux CI. These tasks used 'cmd' which is not available on Unix systems. The core fix for path handling with spaces remains intact in the production code. * test: add comprehensive tests for path handling with spaces Added unit tests: - Test for GetStagedFiles with paths containing spaces - Test for ExecAsync with array arguments - Test for ExecBufferedAsync with array arguments Added integration tests in PathWithSpacesTests.cs: - Test with single spaces in path - Test with multiple consecutive spaces - Test with parentheses and spaces - Test with mixed special characters - Test with complex nested directory structure - Test with multiple files with spaces - Test with relative path mode All tests verify that paths with spaces are properly handled without "fatal: pathspec" errors. Co-authored-by: alirezanet <7004080+alirezanet@users.noreply.github.com> * refactor: restore test config with cross-platform commands and use collection expressions - Restore test variable and task in task-runner.json - Make test commands cross-platform: use bash/echo instead of cmd - Update StagedTask to use C# 12 collection expression syntax - Replace `new List<string> { "add" }; gitAddArgs.AddRange(stagedFiles)` with `["add", ..stagedFiles]` Co-authored-by: alirezanet <7004080+alirezanet@users.noreply.github.com> * refactor: improve test config consistency - Change root-dir variable to use 'pwd' instead of 'ls' to match its purpose - Rename task to 'echo-staged-files' for consistency with kebab-case naming Co-authored-by: alirezanet <7004080+alirezanet@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: alirezanet <7004080+alirezanet@users.noreply.github.com>
1 parent a51fed5 commit 8d4c5c6

9 files changed

Lines changed: 393 additions & 10 deletions

File tree

.husky/commit-msg

100644100755
File mode changed.

.husky/task-runner.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
"variables": [
44
{
55
"name": "root-dir",
6-
"command": "cmd",
7-
"args": ["/c", "dir", "/b"]
6+
"command": "bash",
7+
"args": ["-c", "pwd"]
88
}
99
],
1010
"tasks": [
@@ -19,11 +19,11 @@
1919
"args" :["husky", "exec", ".husky/csx/version-updater.csx", "--args", "${args}"]
2020
},
2121
{
22-
"name": "echo staged files",
22+
"name": "echo-staged-files",
2323
"pathMode": "absolute",
24-
"command": "cmd",
24+
"command": "echo",
2525
"group": "pre-commit",
26-
"args": [ "/c", "echo", "${staged}"]
26+
"args": ["${staged}"]
2727
}
2828
]
2929
}

src/Husky/Services/Contracts/ICliWrap.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ namespace Husky.Services.Contracts;
77
public interface ICliWrap
88
{
99
Task<BufferedCommandResult> ExecBufferedAsync(string fileName, string args);
10+
Task<BufferedCommandResult> ExecBufferedAsync(string fileName, IEnumerable<string> args);
1011
ValueTask SetExecutablePermission(params string[] files);
1112
Task<CommandResult> ExecDirectAsync(string fileName, string args);
13+
Task<CommandResult> ExecDirectAsync(string fileName, IEnumerable<string> args);
1214

1315
Task<CommandResult> RunCommandAsync(string fileName, IEnumerable<string> args, string cwd,
1416
OutputTypes output = OutputTypes.Verbose);

src/Husky/Services/Contracts/IGit.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,5 +35,7 @@ public interface IGit
3535
/// <returns></returns>
3636
Task<string[]> GetDiffStagedRecord();
3737
Task<CommandResult> ExecAsync(string args);
38+
Task<CommandResult> ExecAsync(IEnumerable<string> args);
3839
Task<BufferedCommandResult> ExecBufferedAsync(string args);
40+
Task<BufferedCommandResult> ExecBufferedAsync(IEnumerable<string> args);
3941
}

src/Husky/Services/Git.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,11 +185,21 @@ public Task<CommandResult> ExecAsync(string args)
185185
return _cliWrap.ExecDirectAsync("git", args);
186186
}
187187

188+
public Task<CommandResult> ExecAsync(IEnumerable<string> args)
189+
{
190+
return _cliWrap.ExecDirectAsync("git", args);
191+
}
192+
188193
public Task<BufferedCommandResult> ExecBufferedAsync(string args)
189194
{
190195
return _cliWrap.ExecBufferedAsync("git", args);
191196
}
192197

198+
public Task<BufferedCommandResult> ExecBufferedAsync(IEnumerable<string> args)
199+
{
200+
return _cliWrap.ExecBufferedAsync("git", args);
201+
}
202+
193203
private async Task<string> GetHuskyPath()
194204
{
195205
try

src/Husky/Services/HuskyCliWrap.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,23 @@ public async Task<BufferedCommandResult> ExecBufferedAsync(string fileName, stri
2626
}
2727
}
2828

29+
public async Task<BufferedCommandResult> ExecBufferedAsync(string fileName, IEnumerable<string> args)
30+
{
31+
try
32+
{
33+
var result = await CliWrap.Cli
34+
.Wrap(fileName)
35+
.WithArguments(args)
36+
.ExecuteBufferedAsync();
37+
return result;
38+
}
39+
catch (Exception)
40+
{
41+
$"failed to execute command '{fileName}'".LogErr();
42+
throw;
43+
}
44+
}
45+
2946
public async ValueTask SetExecutablePermission(params string[] files)
3047
{
3148
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
@@ -53,6 +70,18 @@ public async Task<CommandResult> ExecDirectAsync(string fileName, string args)
5370
return result;
5471
}
5572

73+
public async Task<CommandResult> ExecDirectAsync(string fileName, IEnumerable<string> args)
74+
{
75+
var result = await CliWrap.Cli
76+
.Wrap(fileName)
77+
.WithArguments(args)
78+
.WithValidation(CommandResultValidation.None)
79+
.WithStandardOutputPipe(PipeTarget.ToDelegate(q => q.Log()))
80+
.WithStandardErrorPipe(PipeTarget.ToDelegate(q => q.LogErr()))
81+
.ExecuteAsync();
82+
return result;
83+
}
84+
5685
public async Task<CommandResult> RunCommandAsync(
5786
string fileName,
5887
IEnumerable<string> args,

src/Husky/TaskRunner/StagedTask.cs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using System.IO.Abstractions;
2-
using System.Runtime.InteropServices;
32
using System.Text.RegularExpressions;
43
using CliFx.Exceptions;
54
using Husky.Services.Contracts;
@@ -107,7 +106,7 @@ private async Task<double> PartialExecution(List<FileArgumentInfo> partialStaged
107106
foreach (var tf in tmpFiles)
108107
{
109108
// add formatted temp file to git database
110-
var result = await _git.ExecBufferedAsync($"hash-object -w {tf.tmp_path}");
109+
var result = await _git.ExecBufferedAsync(new[] { "hash-object", "-w", tf.tmp_path });
111110
var newHash = result.StandardOutput.Trim();
112111

113112
// check if the partial hash exists
@@ -122,7 +121,7 @@ private async Task<double> PartialExecution(List<FileArgumentInfo> partialStaged
122121
{
123122
$"Updating index entry for {tf.src_path}".LogVerbose();
124123
await _git.ExecAsync(
125-
$"update-index --cacheinfo {tf.dst_mode},{newHash},{tf.src_path}"
124+
new[] { "update-index", "--cacheinfo", $"{tf.dst_mode},{newHash},{tf.src_path}" }
126125
);
127126
}
128127
else
@@ -143,14 +142,17 @@ private async Task ReStageFiles(IEnumerable<FileArgumentInfo> partialStagedFiles
143142
.OfType<FileArgumentInfo>()
144143
.Where(q => q.ArgumentTypes == ArgumentTypes.StagedFile)
145144
.Except(partialStagedFiles)
146-
.Select(q => !RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? $"\"{q.RelativePath}\"" : $"\"{q.RelativePath.Replace("/", @"\")}\"")
145+
.Select(q => q.RelativePath)
147146
.ToList();
148147

149148
if (stagedFiles.Any())
150149
{
151150
"Re-staging staged files...".LogVerbose();
152151
string.Join(Environment.NewLine, stagedFiles).LogVerbose();
153-
await _git.ExecAsync($"add {string.Join(" ", stagedFiles)}");
152+
153+
// Build git add command with file paths as separate arguments
154+
List<string> gitAddArgs = ["add", ..stagedFiles];
155+
await _git.ExecAsync(gitAddArgs);
154156
}
155157
}
156158

0 commit comments

Comments
 (0)