Skip to content

Commit 29582b2

Browse files
geevensinghCopilot
andcommitted
Add --repo/--left/--right/--file CLI flags (closes #5)
DiffViewer is GUI-launch-only today. Add a named-flag command-line form so terminals, shell aliases, and other tools can drive the app into a specific (repo, left, right, [file]) context without going through the launch-context picker. Parser: - CommandLineParser.Parse branches into a new flag-form path when the first positional arg begins with "--". The existing positional grammar (working-tree path, commit-vs-commit, two-commit, PR URL) is untouched. - Required flags: --repo, --left, --right. Optional: --file. - "WORKING" (case-insensitive) maps to DiffSide.WorkingTree; any other value is treated as a commit-ish and resolved through the existing RefResolver path. - New CommandLineErrorKind values: MissingRequiredFlag, MissingFlagValue, UnexpectedPositionalArgument. Unknown flags fail loud (silent ignore would hide difftool-style misconfiguration). - --file is normalized to Path.DirectorySeparatorChar with leading separators trimmed and stored on ParsedCommandLine.InitialFile. File pre-selection: - FileListViewModel.LoadFromChanges takes an optional preferredInitialPath and selects the matching entry by RepoRelativePath (OrdinalIgnoreCase) when there is no prior selection. Doing the selection inside LoadFromChanges keeps it within the IsReloading=true gate window, so the DiffPane reload still fires exactly once from the consolidated PropertyChanged at the end of the load. - MainViewModel accepts an initialFile ctor param and threads it through. - CompositionRoot passes parsed.InitialFile into the MainViewModel ctor. Console / stderr: - New Utility/ConsoleAttacher wraps AttachConsole(ATTACH_PARENT_PROCESS), idempotent, with cached IsAttached state. Best-effort: a double-click launch with no parent console just returns false. - App.OnStartup calls AttachToParent() at the top, then wires a Console.Error.WriteLine-based stderrWriter into MainWindowCoordinator only when attachment succeeded. - MainWindowCoordinator.HandleColdLaunchFailure invokes the writer (try/catch wrapped — best-effort) before the existing dialog and empty-state fallback paths. When launched from Explorer the dialog path is identical to before. README + CHANGELOG: - New "Command-line launch" section documenting the flag grammar plus a working `git` alias recipe (`git config --global alias.dv ...`). - Honest note on why `git difftool` itself does not fit: it passes temp snapshot file paths as \/\, not commit-ish refs, so DiffViewer's flag form (which needs a real repo + refs to drive hunk staging, history, recents) cannot plug into difftool's slots. - `[Unreleased]` "Added" entry covering the new flags and the error behavior. Tests: - CommandLineParserFlagFormTests: 26 tests covering required flags, WORKING sentinel (both sides, case-insensitive), --file normalization, mixed positional+flag rejection, unknown flag, unresolvable refs, missing repo, subdir discovery, ParseLaunch routing. - FileListViewModelInitialPathTests: 7 tests for preferredInitialPath (match, case-insensitive, unmatched, null, empty, prior-selection-wins, commit-vs-commit layout). - ConsoleAttacherTests: 2 tests for idempotency + IsAttached contract. - MainWindowCoordinatorTests: 3 new tests for stderr writer (writes on parse failure, optional/null is still backward-compat, throwing writer does not derail dialog/shutdown). Final state: dotnet build -c Release => 0 warnings, 0 errors. dotnet test => 1199 passed, 1 skipped (unchanged live-network skip), up from the 1161-passing baseline. AI-Local-Session: d30f85c9-a0a8-47d4-8312-c1823ad4c3ec AI-Cloud-Session: 8a8a3c25-8fc8-474f-8993-ac4289b7d964 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent fbfbf53 commit 29582b2

14 files changed

Lines changed: 1084 additions & 7 deletions

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,21 @@ body. Keep section headings exact and write notes in Markdown.
1414

1515
### Added
1616

17+
- **Command-line flags for direct launch**: `DiffViewer.exe --repo
18+
<path> --left <commit-ish | WORKING> --right <commit-ish | WORKING>
19+
[--file <repo-relative-path>]` launches straight into a diff context,
20+
bypassing the launch-context picker. `WORKING` is the sentinel for
21+
"working tree" (matches `DiffSide.WorkingTree`). `--file` pre-selects
22+
a file in the file list, with case-insensitive matching and both
23+
forward- and backward-slash separators accepted. Errors (missing or
24+
unknown flag, missing flag value, unresolvable ref, repo not found)
25+
print to stderr and exit with status 1; when DiffViewer is launched
26+
from a terminal those messages appear inline in that terminal, when
27+
launched from Explorer they fall back to the existing error dialog.
28+
The existing positional grammar (`DiffViewer.exe <path>`,
29+
`DiffViewer.exe <pr-url>`, ...) is unchanged. See the new
30+
"Command-line launch" section in the README for the `git` alias
31+
recipe.
1732
- Hunk overview bar is now interactive for scrolling. Drag the
1833
viewport indicator (the soft blue outline that shows the editors'
1934
currently-visible window) up or down to scroll both editors —

DiffViewer.Tests/MainWindowCoordinatorTests.cs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
24
using System.Threading.Tasks;
35
using DiffViewer;
46
using DiffViewer.Models;
@@ -38,6 +40,74 @@ public async Task InitialLaunchAsync_ParseFailure_ShowsErrorAndShutsDown()
3840
coordinator.Current.Should().BeNull();
3941
}
4042

43+
[Fact]
44+
public async Task InitialLaunchAsync_ParseFailure_AlsoWritesToStderr()
45+
{
46+
// The stderrWriter callback is wired in production from
47+
// App.OnStartup when AttachConsole succeeds; the coordinator just
48+
// forwards every cold-launch failure message through it so CLI
49+
// consumers (git difftool) see the error in their terminal.
50+
var dialog = new FakeDialog();
51+
var services = BuildServices(out _);
52+
var stderr = new List<string>();
53+
54+
var coordinator = new MainWindowCoordinator(
55+
services, dialog, default,
56+
shutdownAction: _ => { },
57+
stderrWriter: stderr.Add);
58+
59+
await coordinator.InitialLaunchAsync(
60+
new[] { "C:\\nope1", "C:\\nope2", "C:\\nope3" });
61+
62+
// Exact wording is the parser's; we just verify the structured
63+
// failure made it through to the stderr callback verbatim.
64+
stderr.Should().ContainSingle()
65+
.Which.Should().Contain("C:\\nope1");
66+
}
67+
68+
[Fact]
69+
public async Task InitialLaunchAsync_ParseFailure_NoStderrWired_StillShowsDialog()
70+
{
71+
// Backward-compat: when no stderrWriter is supplied (GUI launch),
72+
// the existing dialog-and-shutdown path must still fire identically.
73+
var dialog = new FakeDialog();
74+
int? exitCode = null;
75+
var services = BuildServices(out _);
76+
77+
var coordinator = new MainWindowCoordinator(
78+
services, dialog, default,
79+
shutdownAction: c => exitCode = c,
80+
stderrWriter: null);
81+
82+
var ok = await coordinator.InitialLaunchAsync(new[] { "--bogus" });
83+
84+
ok.Should().BeFalse();
85+
dialog.LastError.Should().NotBeNull();
86+
exitCode.Should().Be(1);
87+
}
88+
89+
[Fact]
90+
public async Task InitialLaunchAsync_StderrWriterThrows_DoesNotDerailFailureHandling()
91+
{
92+
// Best-effort contract: a misbehaving stderr writer (e.g. parent
93+
// console closed between attach and write) must not prevent the
94+
// dialog / shutdown from firing.
95+
var dialog = new FakeDialog();
96+
int? exitCode = null;
97+
var services = BuildServices(out _);
98+
99+
var coordinator = new MainWindowCoordinator(
100+
services, dialog, default,
101+
shutdownAction: c => exitCode = c,
102+
stderrWriter: _ => throw new IOException("simulated console gone"));
103+
104+
var ok = await coordinator.InitialLaunchAsync(new[] { "--bogus" });
105+
106+
ok.Should().BeFalse();
107+
dialog.LastError.Should().NotBeNull();
108+
exitCode.Should().Be(1);
109+
}
110+
41111
[Fact]
42112
public async Task StartFromParsedAsync_Success_SetsCurrentAndRecords()
43113
{

0 commit comments

Comments
 (0)