Skip to content

Commit 3776c0e

Browse files
committed
2 parents 5452fbb + 7e14c8e commit 3776c0e

6 files changed

Lines changed: 224 additions & 22 deletions

File tree

readme.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,8 +233,17 @@ var isCopilot = AiCliDetector.IsCopilot;
233233
var isCursor = AiCliDetector.IsCursor;
234234
var isAider = AiCliDetector.IsAider;
235235
var isClaudeCode = AiCliDetector.IsClaudeCode;
236+
var isGeminiCli = AiCliDetector.IsGeminiCli;
237+
var isCodex = AiCliDetector.IsCodex;
238+
var isAmazonQ = AiCliDetector.IsAmazonQ;
239+
var isOpenCode = AiCliDetector.IsOpenCode;
240+
var isCline = AiCliDetector.IsCline;
241+
var isAugment = AiCliDetector.IsAugment;
242+
var isTraeAi = AiCliDetector.IsTraeAi;
243+
var isGoose = AiCliDetector.IsGoose;
244+
var isAmp = AiCliDetector.IsAmp;
236245
```
237-
<sup><a href='/src/DiffEngine.Tests/AiCliDetectorTest.cs#L8-L15' title='Snippet source file'>snippet source</a> | <a href='#snippet-AiCliDetectorProps' title='Start of snippet'>anchor</a></sup>
246+
<sup><a href='/src/DiffEngine.Tests/AiCliDetectorTest.cs#L8-L24' title='Snippet source file'>snippet source</a> | <a href='#snippet-AiCliDetectorProps' title='Start of snippet'>anchor</a></sup>
238247
<!-- endSnippet -->
239248

240249

src/DiffEngine.Tests/AiCliDetectorTest.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@ public void Props()
1111
var isCursor = AiCliDetector.IsCursor;
1212
var isAider = AiCliDetector.IsAider;
1313
var isClaudeCode = AiCliDetector.IsClaudeCode;
14+
var isGeminiCli = AiCliDetector.IsGeminiCli;
15+
var isCodex = AiCliDetector.IsCodex;
16+
var isAmazonQ = AiCliDetector.IsAmazonQ;
17+
var isOpenCode = AiCliDetector.IsOpenCode;
18+
var isCline = AiCliDetector.IsCline;
19+
var isAugment = AiCliDetector.IsAugment;
20+
var isTraeAi = AiCliDetector.IsTraeAi;
21+
var isGoose = AiCliDetector.IsGoose;
22+
var isAmp = AiCliDetector.IsAmp;
1423

1524
#endregion
1625

src/DiffEngine/AiCliDetector.cs

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,47 @@ static AiCliDetector()
2222
// https://cursor.com/docs/agent/terminal
2323
IsCursor = variables.Contains("CURSOR_AGENT");
2424

25+
// Gemini CLI
26+
// https://google-gemini.github.io/gemini-cli/docs/tools/shell.html
27+
IsGeminiCli = variables.Contains("GEMINI_CLI");
28+
29+
// OpenAI Codex CLI
30+
IsCodex = variables.Contains("CODEX_SANDBOX");
31+
32+
// Amazon Q Developer CLI
33+
// https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/command-line.html
34+
IsAmazonQ = variables.Contains("Q_TERM");
35+
36+
// OpenCode
37+
IsOpenCode = variables.Contains("OPENCODE_CLIENT");
38+
39+
// Cline
40+
IsCline = variables.Contains("CLINE_ACTIVE");
41+
42+
// Augment Code
43+
IsAugment = variables.Contains("AUGMENT_AGENT");
44+
45+
// TRAE AI
46+
IsTraeAi = variables.Contains("TRAE_AI_SHELL_ID");
47+
48+
// Goose / Amp share the generic AGENT variable, distinguished by value
49+
var agent = Environment.GetEnvironmentVariable("AGENT");
50+
IsGoose = string.Equals(agent, "goose", StringComparison.OrdinalIgnoreCase);
51+
IsAmp = string.Equals(agent, "amp", StringComparison.OrdinalIgnoreCase);
52+
2553
Detected = IsCopilot ||
2654
IsAider ||
2755
IsClaudeCode ||
28-
IsCursor;
56+
IsCursor ||
57+
IsGeminiCli ||
58+
IsCodex ||
59+
IsAmazonQ ||
60+
IsOpenCode ||
61+
IsCline ||
62+
IsAugment ||
63+
IsTraeAi ||
64+
IsGoose ||
65+
IsAmp;
2966
}
3067

3168
public static bool IsCursor { get; set; }
@@ -36,5 +73,23 @@ static AiCliDetector()
3673

3774
public static bool IsClaudeCode { get; }
3875

76+
public static bool IsGeminiCli { get; }
77+
78+
public static bool IsCodex { get; }
79+
80+
public static bool IsAmazonQ { get; }
81+
82+
public static bool IsOpenCode { get; }
83+
84+
public static bool IsCline { get; }
85+
86+
public static bool IsAugment { get; }
87+
88+
public static bool IsTraeAi { get; }
89+
90+
public static bool IsGoose { get; }
91+
92+
public static bool IsAmp { get; }
93+
3994
public static bool Detected { get; set; }
4095
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
public class FilePurgerTest
2+
{
3+
[Test]
4+
public async Task DeleteSucceeds_WhenFileNotLocked()
5+
{
6+
var file = Path.Combine(Path.GetTempPath(), $"FilePurgerTest_{Guid.NewGuid()}.verified.txt");
7+
File.WriteAllText(file, "content");
8+
9+
var result = FilePurger.TryDeleteWithLockKill(file);
10+
11+
await Assert.That(result.Deleted).IsTrue();
12+
await Assert.That(result.Exception).IsNull();
13+
await Assert.That(File.Exists(file)).IsFalse();
14+
}
15+
16+
[Test]
17+
public async Task DeleteSucceeds_WhenFileDoesNotExist()
18+
{
19+
var file = Path.Combine(Path.GetTempPath(), $"FilePurgerTest_{Guid.NewGuid()}.verified.txt");
20+
21+
var result = FilePurger.TryDeleteWithLockKill(file);
22+
23+
await Assert.That(result.Deleted).IsTrue();
24+
await Assert.That(result.Exception).IsNull();
25+
}
26+
27+
[Test]
28+
public async Task DeleteSucceeds_WhenFileLocked_KillsLockingProcess()
29+
{
30+
var file = Path.Combine(Path.GetTempPath(), $"FilePurgerTest_{Guid.NewGuid()}.verified.txt");
31+
File.WriteAllText(file, "content");
32+
33+
var lockProcess = StartFileLockProcess(file);
34+
35+
try
36+
{
37+
await Assert.That(IsFileLocked(file)).IsTrue();
38+
39+
var result = FilePurger.TryDeleteWithLockKill(file);
40+
41+
await Assert.That(result.Deleted).IsTrue();
42+
await Assert.That(result.Exception).IsNull();
43+
await Assert.That(File.Exists(file)).IsFalse();
44+
45+
var exited = lockProcess.WaitForExit(5000);
46+
await Assert.That(exited).IsTrue();
47+
}
48+
finally
49+
{
50+
if (!lockProcess.HasExited)
51+
{
52+
lockProcess.Kill();
53+
}
54+
55+
lockProcess.Dispose();
56+
57+
if (File.Exists(file))
58+
{
59+
File.Delete(file);
60+
}
61+
}
62+
}
63+
64+
static Process StartFileLockProcess(string path)
65+
{
66+
var script = $"$f = [System.IO.File]::Open('{path.Replace("'", "''")}', 'Open', 'ReadWrite', 'None'); [Console]::WriteLine('locked'); Start-Sleep -Seconds 60";
67+
var process = new Process
68+
{
69+
StartInfo = new()
70+
{
71+
FileName = "powershell.exe",
72+
Arguments = $"-NoProfile -Command \"{script}\"",
73+
UseShellExecute = false,
74+
CreateNoWindow = true,
75+
RedirectStandardOutput = true
76+
}
77+
};
78+
process.Start();
79+
80+
var line = process.StandardOutput.ReadLine();
81+
if (line != "locked")
82+
{
83+
throw new InvalidOperationException($"Expected 'locked' but got '{line}'");
84+
}
85+
86+
return process;
87+
}
88+
89+
static bool IsFileLocked(string path)
90+
{
91+
try
92+
{
93+
using var stream = File.Open(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
94+
return false;
95+
}
96+
catch (IOException)
97+
{
98+
return true;
99+
}
100+
}
101+
}

src/DiffEngineTray/FilePurger.cs

Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -53,36 +53,64 @@ static void DeleteFiles(string[] files)
5353
for (var index = 0; index < files.Length; index++)
5454
{
5555
var file = files[index];
56-
try
56+
var result = TryDeleteWithLockKill(file);
57+
if (result.Deleted)
5758
{
58-
if (File.Exists(file))
59-
{
60-
File.Delete(file);
61-
}
59+
continue;
60+
}
61+
62+
var failedResult = AskQuestion(
63+
$"""
64+
Could not delete file: {file}
65+
Exception: {result.Exception!.Message}
66+
""",
67+
"Delete failed",
68+
MessageBoxButtons.AbortRetryIgnore);
69+
70+
if (failedResult == DialogResult.Abort)
71+
{
72+
return;
73+
}
74+
75+
if (failedResult == DialogResult.Retry)
76+
{
77+
index--;
78+
}
79+
}
80+
}
81+
82+
internal static DeleteResult TryDeleteWithLockKill(string file)
83+
{
84+
try
85+
{
86+
if (File.Exists(file))
87+
{
88+
File.Delete(file);
6289
}
63-
catch (Exception exception)
90+
91+
return new(true, null);
92+
}
93+
catch (Exception exception)
94+
{
95+
if (FileLockKiller.KillLockingProcesses(file))
6496
{
65-
var failedResult = AskQuestion(
66-
$"""
67-
Could not delete file: {file}
68-
Exception: {exception.Message}
69-
""",
70-
"Delete failed",
71-
MessageBoxButtons.AbortRetryIgnore);
72-
73-
if (failedResult == DialogResult.Abort)
97+
try
7498
{
75-
return;
99+
File.Delete(file);
100+
return new(true, null);
76101
}
77-
78-
if (failedResult == DialogResult.Retry)
102+
catch (Exception retryException)
79103
{
80-
index--;
104+
return new(false, retryException);
81105
}
82106
}
107+
108+
return new(false, exception);
83109
}
84110
}
85111

112+
internal record DeleteResult(bool Deleted, Exception? Exception);
113+
86114
static DialogResult AskQuestion(string text, string caption, MessageBoxButtons buttons) =>
87115
MessageBox.Show(
88116
text,

src/Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<Project>
33
<PropertyGroup>
44
<NoWarn>CS1591;CS0649;NU1608;NU1109</NoWarn>
5-
<Version>19.1.3</Version>
5+
<Version>19.2.0</Version>
66
<AssemblyVersion>1.0.0</AssemblyVersion>
77
<PackageTags>Testing, Snapshot, Diff, Compare</PackageTags>
88
<Description>Launches diff tools based on file extensions. Designed to be consumed by snapshot testing libraries.</Description>

0 commit comments

Comments
 (0)