From b28e914cf8c53f2e727d41b0a5f1fd01bc14f5d6 Mon Sep 17 00:00:00 2001 From: Sammy Valtonen Date: Thu, 9 Jul 2026 15:16:53 +0200 Subject: [PATCH] Add Analyze Changed Files action for Git-tracked projects --- client/source/DelphiLint.Plugin.dfm | 7 + client/source/DelphiLint.Plugin.pas | 85 +++++++ client/source/DelphiLint.ToolFrame.dfm | 3 + client/source/DelphiLint.ToolFrame.pas | 1 + client/source/DelphiLint.VersionControl.pas | 227 ++++++++++++++++++ client/source/DelphiLintClient280.dpk | 1 + client/source/DelphiLintClient280.dproj | 1 + client/source/DelphiLintClient290.dpk | 1 + client/source/DelphiLintClient290.dproj | 1 + client/source/DelphiLintClient370.dpk | 1 + client/source/DelphiLintClient370.dproj | 1 + client/test/DelphiLintClientTest280.dpr | 3 +- client/test/DelphiLintClientTest280.dproj | 1 + client/test/DelphiLintClientTest290.dpr | 3 +- client/test/DelphiLintClientTest290.dproj | 1 + client/test/DelphiLintClientTest370.dpr | 3 +- client/test/DelphiLintClientTest370.dproj | 1 + client/test/DelphiLintTest.VersionControl.pas | 129 ++++++++++ 18 files changed, 467 insertions(+), 3 deletions(-) create mode 100644 client/source/DelphiLint.VersionControl.pas create mode 100644 client/test/DelphiLintTest.VersionControl.pas diff --git a/client/source/DelphiLint.Plugin.dfm b/client/source/DelphiLint.Plugin.dfm index 859d6830..6ce86ede 100644 --- a/client/source/DelphiLint.Plugin.dfm +++ b/client/source/DelphiLint.Plugin.dfm @@ -305,6 +305,13 @@ object PluginCore: TPluginCore ShortCut = 57420 OnExecute = ActionAnalyzeOpenFilesExecute end + object ActionAnalyzeChangedFiles: TAction + Category = 'DelphiLint' + Caption = 'Analyze &Changed Files' + Hint = 'Analyze all files with uncommitted version control changes' + ImageIndex = 2 + OnExecute = ActionAnalyzeChangedFilesExecute + end object ActionAnalyzeShort: TAction Caption = 'Analyze' ImageIndex = 3 diff --git a/client/source/DelphiLint.Plugin.pas b/client/source/DelphiLint.Plugin.pas index 5d7bd7bd..e4a3ce3e 100644 --- a/client/source/DelphiLint.Plugin.pas +++ b/client/source/DelphiLint.Plugin.pas @@ -46,12 +46,14 @@ TPluginCore = class(TDataModule) ActionOpenProjectOptions: TAction; ActionOpenSettings: TAction; ActionAnalyzeOpenFiles: TAction; + ActionAnalyzeChangedFiles: TAction; ActionRestartServer: TAction; ActionClearActiveFile: TAction; procedure ActionShowToolWindowExecute(Sender: TObject); procedure ActionAnalyzeActiveFileExecute(Sender: TObject); procedure ActionRestartServerExecute(Sender: TObject); procedure ActionAnalyzeOpenFilesExecute(Sender: TObject); + procedure ActionAnalyzeChangedFilesExecute(Sender: TObject); procedure ActionOpenSettingsExecute(Sender: TObject); procedure ActionOpenProjectOptionsExecute(Sender: TObject); procedure ActionClearActiveFileExecute(Sender: TObject); @@ -119,10 +121,12 @@ implementation System.SysUtils , System.UITypes , System.IOUtils + , System.StrUtils , Vcl.ComCtrls , Vcl.Dialogs , Winapi.Windows , DelphiLint.Utils + , DelphiLint.VersionControl , DelphiLint.SetupForm , DelphiLint.Version , DelphiLint.Resources @@ -226,6 +230,83 @@ procedure TPluginCore.ActionAnalyzeOpenFilesExecute(Sender: TObject); //______________________________________________________________________________________________________________________ +procedure TPluginCore.ActionAnalyzeChangedFilesExecute(Sender: TObject); +var + ProjectFile: string; + ProjectDir: string; + ChangedFiles: TArray; + NormalizedChangedFiles: TArray; + Files: TArray; + Module: IIDEModule; +begin + if LintContext.Settings.ClientAutoShowToolWindow then begin + ShowToolWindow; + end; + + if not TryGetProjectFile(ProjectFile) then begin + TaskMessageDlg( + 'DelphiLint cannot analyze changed files.', + 'There is no open Delphi project.', + mtWarning, + [mbOK], + 0); + Exit; + end; + + if not TryGetProjectDirectory(ProjectDir) then begin + ProjectDir := TPath.GetDirectoryName(ProjectFile); + end; + + if not TryGetChangedDelphiFiles(ProjectDir, ChangedFiles) then begin + TaskMessageDlg( + 'DelphiLint cannot analyze changed files.', + 'The project is not in a Git repository, or Git could not be run.', + mtWarning, + [mbOK], + 0); + Exit; + end; + + if Length(ChangedFiles) = 0 then begin + TaskMessageDlg( + 'DelphiLint cannot analyze changed files.', + 'There are no changed files that can be analyzed.', + mtWarning, + [mbOK], + 0); + Exit; + end; + + if LintContext.Settings.ClientSaveBeforeAnalysis then begin + NormalizedChangedFiles := TArrayUtils.Map( + ChangedFiles, + function(Path: string): string + begin + Result := NormalizePath(Path); + end); + + for Module in DelphiLint.Utils.GetOpenSourceModules do begin + if IndexStr(NormalizePath(Module.FileName), NormalizedChangedFiles) <> -1 then begin + try + Module.Save(True); + except + on E: Exception do begin + Log.Warn('Module %s could not be saved', [Module.FileName]); + end; + end; + end; + end; + end; + + Files := Copy(ChangedFiles); + SetLength(Files, Length(Files) + 1); + Files[Length(Files) - 1] := ProjectFile; + + Analyzer.AnalyzeFiles(Files, ProjectFile); +end; + +//______________________________________________________________________________________________________________________ + procedure TPluginCore.ActionClearActiveFileExecute(Sender: TObject); var SourceEditor: IIDESourceEditor; @@ -406,6 +487,7 @@ procedure TPluginCore.CreateMainMenu; AddSeparator; AddItem(ActionAnalyzeActiveFile); AddItem(ActionAnalyzeOpenFiles); + AddItem(ActionAnalyzeChangedFiles); AddSeparator; AddItem(ActionClearActiveFile); AddSeparator; @@ -469,6 +551,7 @@ procedure TPluginCore.RefreshAnalysisActions; ActionAnalyzeActiveFile.Enabled := True; ActionAnalyzeShort.Enabled := True; ActionAnalyzeOpenFiles.Enabled := True; + ActionAnalyzeChangedFiles.Enabled := True; ActionClearActiveFile.Enabled := TryGetCurrentSourceEditor(SourceEditor) and (Length(Analyzer.GetIssues(SourceEditor.FileName)) > 0); end @@ -476,6 +559,7 @@ procedure TPluginCore.RefreshAnalysisActions; ActionAnalyzeActiveFile.Enabled := False; ActionAnalyzeShort.Enabled := False; ActionAnalyzeOpenFiles.Enabled := False; + ActionAnalyzeChangedFiles.Enabled := False; ActionClearActiveFile.Enabled := False; end; end; @@ -511,6 +595,7 @@ procedure TPluginCore.RemoveToolbarActions(IDEServices: IIDEServices); for ToolBar in CToolBars do begin RemoveAction(ActionAnalyzeActiveFile, IDEServices.GetToolBar(ToolBar)); RemoveAction(ActionAnalyzeOpenFiles, IDEServices.GetToolBar(ToolBar)); + RemoveAction(ActionAnalyzeChangedFiles, IDEServices.GetToolBar(ToolBar)); RemoveAction(ActionShowToolWindow, IDEServices.GetToolBar(ToolBar)); RemoveAction(ActionOpenProjectOptions, IDEServices.GetToolBar(ToolBar)); RemoveAction(ActionOpenSettings, IDEServices.GetToolBar(ToolBar)); diff --git a/client/source/DelphiLint.ToolFrame.dfm b/client/source/DelphiLint.ToolFrame.dfm index 84977704..451ec399 100644 --- a/client/source/DelphiLint.ToolFrame.dfm +++ b/client/source/DelphiLint.ToolFrame.dfm @@ -324,6 +324,9 @@ object LintToolFrame: TLintToolFrame object AnalyzeOpenFiles1: TMenuItem Action = PluginCore.ActionAnalyzeOpenFiles end + object AnalyzeChangedFiles1: TMenuItem + Action = PluginCore.ActionAnalyzeChangedFiles + end object Separator1: TMenuItem Caption = '-' end diff --git a/client/source/DelphiLint.ToolFrame.pas b/client/source/DelphiLint.ToolFrame.pas index 129999d5..cff95f64 100644 --- a/client/source/DelphiLint.ToolFrame.pas +++ b/client/source/DelphiLint.ToolFrame.pas @@ -74,6 +74,7 @@ TLintToolFrame = class(TFrame) AnalyzePopupMenu: TPopupMenu; AnalyzeCurrentFile1: TMenuItem; AnalyzeOpenFiles1: TMenuItem; + AnalyzeChangedFiles1: TMenuItem; StatusPanel: TPanel; ProgLabel: TLabel; ResizeIndicatorPanel: TPanel; diff --git a/client/source/DelphiLint.VersionControl.pas b/client/source/DelphiLint.VersionControl.pas new file mode 100644 index 00000000..46c37c21 --- /dev/null +++ b/client/source/DelphiLint.VersionControl.pas @@ -0,0 +1,227 @@ +{ +DelphiLint Client +Copyright (C) 2024 Integrated Application Development + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 3 of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +} +unit DelphiLint.VersionControl; + +interface + +// Retrieves the Delphi source files that the Git repository containing BaseDir reports as +// changed (modified, added, or untracked). Returns False if BaseDir is not in a Git repository +// or Git could not be run. +function TryGetChangedDelphiFiles(const BaseDir: string; out ChangedFiles: TArray): Boolean; + +// Converts `git status --porcelain` output lines to absolute paths, excluding deleted files. +// Exposed for testing. +function ParseGitStatusOutput(const StatusLines: TArray; const RepoRoot: string): TArray; + +implementation + +uses + System.SysUtils + , System.Classes + , System.IOUtils + , System.Generics.Collections + , Winapi.Windows + , DelphiLint.Utils + ; + +//______________________________________________________________________________________________________________________ + +function TryRunGit(const Args: string; const WorkingDir: string; out Output: TArray): Boolean; +const + CBufferSize = 4096; + CProcessTimeoutMillis = 10000; +var + SecurityAttributes: TSecurityAttributes; + ReadPipe: THandle; + WritePipe: THandle; + StartupInfo: TStartupInfo; + ProcessInfo: TProcessInformation; + CommandLine: string; + Buffer: array[0..CBufferSize - 1] of Byte; + BytesRead: Cardinal; + OutputStream: TBytesStream; + ExitCode: Cardinal; +begin + Result := False; + Output := []; + + SecurityAttributes.nLength := SizeOf(TSecurityAttributes); + SecurityAttributes.bInheritHandle := True; + SecurityAttributes.lpSecurityDescriptor := nil; + + if not CreatePipe(ReadPipe, WritePipe, @SecurityAttributes, 0) then begin + Exit; + end; + + OutputStream := TBytesStream.Create; + try + // The read end must not be inherited, or the pipe stays open after the process exits + SetHandleInformation(ReadPipe, HANDLE_FLAG_INHERIT, 0); + + ZeroMemory(@StartupInfo, SizeOf(TStartupInfo)); + StartupInfo.cb := SizeOf(TStartupInfo); + StartupInfo.dwFlags := STARTF_USESTDHANDLES; + StartupInfo.hStdOutput := WritePipe; + + // CreateProcess may modify the command line buffer, so it cannot share string memory + CommandLine := 'git ' + Args; + UniqueString(CommandLine); + + if not CreateProcess( + nil, + PChar(CommandLine), + nil, + nil, + True, + CREATE_NO_WINDOW, + nil, + PChar(WorkingDir), + StartupInfo, + ProcessInfo + ) then begin + CloseHandle(WritePipe); + Exit; + end; + + // Close our copy of the write end so reads terminate when the process exits + CloseHandle(WritePipe); + + while ReadFile(ReadPipe, Buffer, CBufferSize, BytesRead, nil) and (BytesRead > 0) do begin + OutputStream.WriteBuffer(Buffer, BytesRead); + end; + + Result := + (WaitForSingleObject(ProcessInfo.hProcess, CProcessTimeoutMillis) = WAIT_OBJECT_0) + and GetExitCodeProcess(ProcessInfo.hProcess, ExitCode) + and (ExitCode = 0); + + CloseHandle(ProcessInfo.hProcess); + CloseHandle(ProcessInfo.hThread); + + if Result then begin + Output := TEncoding.UTF8 + .GetString(OutputStream.Bytes, 0, OutputStream.Size) + .Replace(#13, '') + .Split([#10]); + end; + finally + FreeAndNil(OutputStream); + CloseHandle(ReadPipe); + end; +end; + +//______________________________________________________________________________________________________________________ + +function UnquoteGitPath(const Path: string): string; +begin + Result := Path; + if (Length(Result) >= 2) and (Result[1] = '"') and (Result[Length(Result)] = '"') then begin + Result := Copy(Result, 2, Length(Result) - 2); + Result := StringReplace(Result, '\"', '"', [rfReplaceAll]); + Result := StringReplace(Result, '\\', '\', [rfReplaceAll]); + end; +end; + +//______________________________________________________________________________________________________________________ + +function ParseGitStatusOutput(const StatusLines: TArray; const RepoRoot: string): TArray; +var + Files: TList; + Line: string; + FilePath: string; +begin + Files := TList.Create; + try + // Porcelain format is XY , where X is the index status and Y is the working tree status + for Line in StatusLines do begin + if Length(Line) < 4 then begin + Continue; + end; + + // Deleted files no longer exist in the working tree, so they cannot be analyzed + if (Line[1] = 'D') or (Line[2] = 'D') then begin + Continue; + end; + + FilePath := UnquoteGitPath(Copy(Line, 4, Length(Line))); + FilePath := StringReplace(FilePath, '/', '\', [rfReplaceAll]); + // Validation is skipped so that unrepresentable paths are passed through instead of raising - + // nonexistent files are filtered out downstream + Files.Add(TPath.Combine(RepoRoot, FilePath, False)); + end; + + Result := Files.ToArray; + finally + FreeAndNil(Files); + end; +end; + +//______________________________________________________________________________________________________________________ + +function TryGetChangedDelphiFiles(const BaseDir: string; out ChangedFiles: TArray): Boolean; +var + CdupOutput: TArray; + StatusOutput: TArray; + RepoRelativeRoot: string; + RepoRoot: string; + Files: TList; + FilePath: string; +begin + Result := False; + ChangedFiles := []; + + // The relative path to the repository root is used instead of --show-toplevel, as the latter + // resolves mapped and substituted drives to physical paths that don't match the IDE's paths + if not TryRunGit('rev-parse --show-cdup', BaseDir, CdupOutput) then begin + Exit; + end; + + RepoRelativeRoot := ''; + if Length(CdupOutput) > 0 then begin + RepoRelativeRoot := StringReplace(Trim(CdupOutput[0]), '/', '\', [rfReplaceAll]); + end; + + if RepoRelativeRoot = '' then begin + RepoRoot := BaseDir; + end + else begin + RepoRoot := ToAbsolutePath(RepoRelativeRoot, BaseDir); + end; + + // quotepath is disabled so that non-ASCII paths are emitted verbatim instead of escaped + if not TryRunGit('-c core.quotepath=off status --porcelain --no-renames', BaseDir, StatusOutput) then begin + Exit; + end; + + Files := TList.Create; + try + for FilePath in ParseGitStatusOutput(StatusOutput, RepoRoot) do begin + if IsDelphiSource(FilePath) and FileExists(FilePath) then begin + Files.Add(FilePath); + end; + end; + + ChangedFiles := Files.ToArray; + finally + FreeAndNil(Files); + end; + + Result := True; +end; + +end. diff --git a/client/source/DelphiLintClient280.dpk b/client/source/DelphiLintClient280.dpk index 2cdd5984..c8c0c10c 100644 --- a/client/source/DelphiLintClient280.dpk +++ b/client/source/DelphiLintClient280.dpk @@ -54,6 +54,7 @@ contains DelphiLint.Plugin in 'DelphiLint.Plugin.pas' {PluginCore: TDataModule}, DelphiLint.ToolFrame in 'DelphiLint.ToolFrame.pas' {LintToolFrame: T}, DelphiLint.Utils in 'DelphiLint.Utils.pas', + DelphiLint.VersionControl in 'DelphiLint.VersionControl.pas', DelphiLint.SettingsFrame in 'DelphiLint.SettingsFrame.pas' {LintSettingsFrame: TFrame}, DelphiLint.OptionsForm in 'DelphiLint.OptionsForm.pas' {LintOptionsForm}, DelphiLint.Properties in 'DelphiLint.Properties.pas', diff --git a/client/source/DelphiLintClient280.dproj b/client/source/DelphiLintClient280.dproj index 00f0020f..150e7758 100644 --- a/client/source/DelphiLintClient280.dproj +++ b/client/source/DelphiLintClient280.dproj @@ -116,6 +116,7 @@ $(PreBuildEvent)]]> T +
LintSettingsFrame
dfm diff --git a/client/source/DelphiLintClient290.dpk b/client/source/DelphiLintClient290.dpk index a6ba317b..950b9947 100644 --- a/client/source/DelphiLintClient290.dpk +++ b/client/source/DelphiLintClient290.dpk @@ -54,6 +54,7 @@ contains DelphiLint.Plugin in 'DelphiLint.Plugin.pas' {PluginCore: TDataModule}, DelphiLint.ToolFrame in 'DelphiLint.ToolFrame.pas' {LintToolFrame: T}, DelphiLint.Utils in 'DelphiLint.Utils.pas', + DelphiLint.VersionControl in 'DelphiLint.VersionControl.pas', DelphiLint.SettingsFrame in 'DelphiLint.SettingsFrame.pas' {LintSettingsFrame: TFrame}, DelphiLint.OptionsForm in 'DelphiLint.OptionsForm.pas' {LintOptionsForm}, DelphiLint.Properties in 'DelphiLint.Properties.pas', diff --git a/client/source/DelphiLintClient290.dproj b/client/source/DelphiLintClient290.dproj index 50827e9a..3d641565 100644 --- a/client/source/DelphiLintClient290.dproj +++ b/client/source/DelphiLintClient290.dproj @@ -117,6 +117,7 @@ $(PreBuildEvent)]]> T
+
LintSettingsFrame
dfm diff --git a/client/source/DelphiLintClient370.dpk b/client/source/DelphiLintClient370.dpk index d68ae804..3c89ae7d 100644 --- a/client/source/DelphiLintClient370.dpk +++ b/client/source/DelphiLintClient370.dpk @@ -54,6 +54,7 @@ contains DelphiLint.Plugin in 'DelphiLint.Plugin.pas' {PluginCore: TDataModule}, DelphiLint.ToolFrame in 'DelphiLint.ToolFrame.pas' {LintToolFrame: T}, DelphiLint.Utils in 'DelphiLint.Utils.pas', + DelphiLint.VersionControl in 'DelphiLint.VersionControl.pas', DelphiLint.SettingsFrame in 'DelphiLint.SettingsFrame.pas' {LintSettingsFrame: TFrame}, DelphiLint.OptionsForm in 'DelphiLint.OptionsForm.pas' {LintOptionsForm}, DelphiLint.Properties in 'DelphiLint.Properties.pas', diff --git a/client/source/DelphiLintClient370.dproj b/client/source/DelphiLintClient370.dproj index 5761ee1f..235c51fa 100644 --- a/client/source/DelphiLintClient370.dproj +++ b/client/source/DelphiLintClient370.dproj @@ -113,6 +113,7 @@ $(PreBuildEvent)]]> T
+
LintSettingsFrame
dfm diff --git a/client/test/DelphiLintClientTest280.dpr b/client/test/DelphiLintClientTest280.dpr index c1bde108..3c4c9306 100644 --- a/client/test/DelphiLintClientTest280.dpr +++ b/client/test/DelphiLintClientTest280.dpr @@ -34,7 +34,8 @@ uses DelphiLintTest.Settings in 'DelphiLintTest.Settings.pas', DelphiLintTest.LiveData in 'DelphiLintTest.LiveData.pas', DelphiLintTest.IssueActions in 'DelphiLintTest.IssueActions.pas', - DelphiLintTest.Properties in 'DelphiLintTest.Properties.pas'; + DelphiLintTest.Properties in 'DelphiLintTest.Properties.pas', + DelphiLintTest.VersionControl in 'DelphiLintTest.VersionControl.pas'; {$R *Additional.res} diff --git a/client/test/DelphiLintClientTest280.dproj b/client/test/DelphiLintClientTest280.dproj index 15dd9d97..8f829b0c 100644 --- a/client/test/DelphiLintClientTest280.dproj +++ b/client/test/DelphiLintClientTest280.dproj @@ -151,6 +151,7 @@ $(PreBuildEvent)]]> + Base diff --git a/client/test/DelphiLintClientTest290.dpr b/client/test/DelphiLintClientTest290.dpr index acff7782..2a2914f4 100644 --- a/client/test/DelphiLintClientTest290.dpr +++ b/client/test/DelphiLintClientTest290.dpr @@ -34,7 +34,8 @@ uses DelphiLintTest.Settings in 'DelphiLintTest.Settings.pas', DelphiLintTest.LiveData in 'DelphiLintTest.LiveData.pas', DelphiLintTest.IssueActions in 'DelphiLintTest.IssueActions.pas', - DelphiLintTest.Properties in 'DelphiLintTest.Properties.pas'; + DelphiLintTest.Properties in 'DelphiLintTest.Properties.pas', + DelphiLintTest.VersionControl in 'DelphiLintTest.VersionControl.pas'; {$R *Additional.res} diff --git a/client/test/DelphiLintClientTest290.dproj b/client/test/DelphiLintClientTest290.dproj index d99fb5aa..2a47f365 100644 --- a/client/test/DelphiLintClientTest290.dproj +++ b/client/test/DelphiLintClientTest290.dproj @@ -152,6 +152,7 @@ $(PreBuildEvent)]]> + Base diff --git a/client/test/DelphiLintClientTest370.dpr b/client/test/DelphiLintClientTest370.dpr index 800210e9..bcc65914 100644 --- a/client/test/DelphiLintClientTest370.dpr +++ b/client/test/DelphiLintClientTest370.dpr @@ -34,7 +34,8 @@ uses DelphiLintTest.Settings in 'DelphiLintTest.Settings.pas', DelphiLintTest.LiveData in 'DelphiLintTest.LiveData.pas', DelphiLintTest.IssueActions in 'DelphiLintTest.IssueActions.pas', - DelphiLintTest.Properties in 'DelphiLintTest.Properties.pas'; + DelphiLintTest.Properties in 'DelphiLintTest.Properties.pas', + DelphiLintTest.VersionControl in 'DelphiLintTest.VersionControl.pas'; {$R *Additional.res} diff --git a/client/test/DelphiLintClientTest370.dproj b/client/test/DelphiLintClientTest370.dproj index 7ef380da..b1d7f11f 100644 --- a/client/test/DelphiLintClientTest370.dproj +++ b/client/test/DelphiLintClientTest370.dproj @@ -150,6 +150,7 @@ $(PreBuildEvent)]]> + Base diff --git a/client/test/DelphiLintTest.VersionControl.pas b/client/test/DelphiLintTest.VersionControl.pas new file mode 100644 index 00000000..ad9ccd62 --- /dev/null +++ b/client/test/DelphiLintTest.VersionControl.pas @@ -0,0 +1,129 @@ +{ +DelphiLint Client +Copyright (C) 2024 Integrated Application Development + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 3 of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +} +unit DelphiLintTest.VersionControl; + +interface + +uses + DUnitX.TestFramework + ; + +type + [TestFixture] + TVersionControlTest = class(TObject) + public + [Test] + procedure TestParsesChangedFiles; + [Test] + procedure TestExcludesDeletedFiles; + [Test] + procedure TestIgnoresMalformedLines; + [Test] + procedure TestUnquotesQuotedPaths; + end; + +implementation + +uses + DelphiLint.VersionControl + ; + +//______________________________________________________________________________________________________________________ + +procedure TVersionControlTest.TestParsesChangedFiles; +var + Files: TArray; +begin + Files := ParseGitStatusOutput( + [ + ' M src/modified.pas', + 'M src/staged.pas', + 'MM src/both.pas', + 'A added.pas', + '?? untracked.pas' + ], + 'C:\repo'); + + Assert.AreEqual(5, Length(Files)); + Assert.AreEqual('C:\repo\src\modified.pas', Files[0]); + Assert.AreEqual('C:\repo\src\staged.pas', Files[1]); + Assert.AreEqual('C:\repo\src\both.pas', Files[2]); + Assert.AreEqual('C:\repo\added.pas', Files[3]); + Assert.AreEqual('C:\repo\untracked.pas', Files[4]); +end; + +//______________________________________________________________________________________________________________________ + +procedure TVersionControlTest.TestExcludesDeletedFiles; +var + Files: TArray; +begin + Files := ParseGitStatusOutput( + [ + ' D worktreedeleted.pas', + 'D stagedeleted.pas', + ' M kept.pas' + ], + 'C:\repo'); + + Assert.AreEqual(1, Length(Files)); + Assert.AreEqual('C:\repo\kept.pas', Files[0]); +end; + +//______________________________________________________________________________________________________________________ + +procedure TVersionControlTest.TestIgnoresMalformedLines; +var + Files: TArray; +begin + Files := ParseGitStatusOutput( + [ + '', + 'M', + ' M valid.pas' + ], + 'C:\repo'); + + Assert.AreEqual(1, Length(Files)); + Assert.AreEqual('C:\repo\valid.pas', Files[0]); +end; + +//______________________________________________________________________________________________________________________ + +procedure TVersionControlTest.TestUnquotesQuotedPaths; +var + Files: TArray; +begin + Files := ParseGitStatusOutput( + [ + ' M "src/my \"quoted\" file.pas"', + ' M "src/back\\slash.pas"' + ], + 'C:\repo'); + + Assert.AreEqual(2, Length(Files)); + Assert.AreEqual('C:\repo\src\my "quoted" file.pas', Files[0]); + Assert.AreEqual('C:\repo\src\back\slash.pas', Files[1]); +end; + +//______________________________________________________________________________________________________________________ + +initialization + TDUnitX.RegisterTestFixture(TVersionControlTest); + +end.