refactor: migrate git+file groups to ports with IGitRepo (PR 3/6 of #2)#5
Conversation
Lands Phase 4 of the ports-and-adapters seam: - IGitRepo port + GitRepoAdapter + FakeGitRepo fluent builder. Five structured probes replace ad-hoc git invocations duplicated between GitCommands (three rev-parses in RunCommitAmend) and FileCommands (two diffs in RunDeleteTracked, plus ls-files in RunMove). - GitCommands and FileCommands migrated to Func<Ports, string[], int>. Their Blocked output now goes through IRenderer, so the structured JSON envelope rolls over to two more groups (now 8 of 12 migrated). - Ports record gains a Git port; legacy ctor shim still adapts the four remaining unmigrated groups (env, process, proxy, generate). - 37 new tests (133 total). The previously-impossible assertion "commit-amend is blocked when HEAD is pushed" is now a one-liner via FakeGitRepo.WithPushedHead — no real repo required. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces the IGitRepo port and its implementation GitRepoAdapter to centralize git state probes, replacing ad-hoc git calls in GitCommands and FileCommands. It also migrates these command groups to the structured Ports pattern and adds comprehensive test coverage. Feedback includes suggestions to optimize GitRepoAdapter methods by reducing git invocations and removing hardcoded remote names, as well as addressing logic duplication in GitCommands.
| public HeadStatus GetHeadStatus() | ||
| { | ||
| var branch = exec.Run("git", ["rev-parse", "--abbrev-ref", "HEAD"]); | ||
| if (branch.ExitCode != 0) return default; | ||
| var branchName = branch.StdOut.Trim(); | ||
| if (string.IsNullOrEmpty(branchName) || branchName == "HEAD") return default; | ||
|
|
||
| var headHash = exec.Run("git", ["rev-parse", "HEAD"]); | ||
| var upstream = $"origin/{branchName}"; | ||
| var remoteHash = exec.Run("git", ["rev-parse", upstream]); | ||
|
|
||
| var pushed = remoteHash.ExitCode == 0 | ||
| && !string.IsNullOrEmpty(headHash.StdOut.Trim()) | ||
| && headHash.StdOut.Trim() == remoteHash.StdOut.Trim(); | ||
|
|
||
| return new HeadStatus(branchName, pushed ? upstream : null, pushed); | ||
| } |
There was a problem hiding this comment.
The current implementation hardcodes origin/ as the remote name, which will fail for users with different remote configurations (e.g., upstream). Additionally, it uses three git invocations. Both issues can be resolved by using git status --porcelain -b, which provides the branch name, upstream tracking branch, and sync status in a single call.
public HeadStatus GetHeadStatus()
{
var r = exec.Run("git", ["status", "--porcelain", "-b"]);
if (r.ExitCode != 0 || string.IsNullOrWhiteSpace(r.StdOut)) return default;
var line = r.StdOut.Split('\n', StringSplitOptions.RemoveEmptyEntries)[0];
if (!line.StartsWith("## ")) return default;
var content = line[3..].Trim();
if (content.Contains("..."))
{
var parts = content.Split("...", 2);
var branch = parts[0];
var remainder = parts[1];
var upstreamParts = remainder.Split(' ', 2);
var upstream = upstreamParts[0];
var isPushed = upstreamParts.Length == 1;
return new HeadStatus(branch, upstream, isPushed);
}
return new HeadStatus(content.Contains(' ') ? null : content, null, false);
}There was a problem hiding this comment.
Fixed in 75ab596. Switched to a single git status --porcelain -b and parse the upstream from its first line, so the remote name (origin, upstream, fork, …) is whatever git reports rather than hard-coded. IsPushed is now "upstream is set AND no [ahead marker" — [behind N] alone still counts as pushed since HEAD is at a published commit.
| public bool HasPendingChanges(string relativePath) | ||
| { | ||
| var unstaged = exec.Run("git", ["diff", "--name-only", relativePath]); | ||
| if (!string.IsNullOrWhiteSpace(unstaged.StdOut)) return true; | ||
| var staged = exec.Run("git", ["diff", "--staged", "--name-only", relativePath]); | ||
| return !string.IsNullOrWhiteSpace(staged.StdOut); | ||
| } |
There was a problem hiding this comment.
This method can be optimized to a single git invocation using git status --porcelain. This is more efficient and aligns with the stated design goal of using a single invocation per probe.
public bool HasPendingChanges(string relativePath)
{
var r = exec.Run("git", ["status", "--porcelain", "--", relativePath]);
return r.ExitCode == 0 && !string.IsNullOrWhiteSpace(r.StdOut);
}There was a problem hiding this comment.
Fixed in 75ab596 — single git status --porcelain -- <path> covers staged and unstaged. Callers (RunDeleteTracked) already gate with IsFileTracked first, so untracked ?? lines won't show up here.
| bool HasPendingChanges(string relativePath); | ||
|
|
||
| /// <summary> | ||
| /// Status of HEAD relative to <c>origin/<branch></c>. <see cref="HeadStatus.IsPushed"/> |
There was a problem hiding this comment.
There was a problem hiding this comment.
Fixed in 75ab596 — doc updated to say "its configured upstream tracking branch (whatever remote is set — not hard-coded to origin)" and clarifies that IsPushed means "upstream is set AND no local-only commits ahead".
| var offending = args.FirstOrDefault(a => | ||
| a is "--force" or "-f" or "--delete" or "--no-verify") ?? ""; |
There was a problem hiding this comment.
This manual check duplicates the flags already defined in PushPolicy (line 14). This creates a maintenance burden and potential for inconsistency if the policy is updated but this error message logic is forgotten. Consider extracting the blocked flags into a shared constant or finding a way to derive the offending flag from the policy evaluation.
There was a problem hiding this comment.
Fixed in 75ab596 — extracted PushBlockedFlags as a static HashSet<string> and spread it into both PushPolicy = Policy.Default.DenyFlags([..PushBlockedFlags]) and the offending-arg lookup. Same single-source-of-truth pattern Phase 3 used for DestructiveFlags in DbCommands.
- GetHeadStatus now uses 'git status --porcelain -b' (one call instead of three) and parses the upstream tracking branch from output rather than hard-coding 'origin/<branch>'. Works for users whose remote is named 'upstream', 'fork', etc. - HasPendingChanges collapses to a single 'git status --porcelain -- <path>' invocation (was a staged + unstaged diff pair). - IGitRepo doc no longer claims 'origin/<branch>'. - GitCommands.RunPush no longer duplicates the push-blocked flag list; PushBlockedFlags is the single source of truth for both PushPolicy (gating) and the offending-arg lookup (envelope label). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Phase 4 of #2 — migrate
GitCommandsandFileCommandsto ports, introducing theIGitRepoport that earns its keep by deduplicating git state probes.Stacked on #4 (Phase 3). Targets the Phase 3 branch; will rebase to
mainonce #4 merges.What's new
IGitRepoport with five structured probes —IsRepo,IsWorkingTreeClean,IsFileTracked,HasPendingChanges,GetHeadStatus. Each is answerable by a single git invocation (per the RFC contract).GitRepoAdapterreal implementation;FakeGitRepofluent-builder fake (AsRepo,WithCleanTree,WithTracked,WithPushedHead, etc.).Portsrecord now carries a third port:Ports(IExecutor Exec, IRenderer Render, IGitRepo Git).GitCommands/FileCommandsmigrated toFunc<Ports, string[], int>. The threerev-parsecalls inRunCommitAmendcollapse to oneGetHeadStatus(); the three git invocations inRunDeleteTrackedcollapse toIsFileTracked+HasPendingChanges.--jsonextends to two more groups (now 8 of 12 migrated). The remaining four —env,process,proxy,generate— still emit Spectre markup until later phases.Tests
GitCommandsTests,FileCommandsTests); total 133.FileCommandsTestsmutates process-wide cwd, so it lives in a[Collection]with parallelization disabled to avoid clobbering peer tests.Behaviour notes
safe git ...andsafe file ...invocations behave identically in human mode. JSON-mode blocked output now follows the structured envelope shape introduced in 0.4.0.safe file mkdir|copy|write|move|delete-trackedsuccess messages used to render in green viaOutputFormatter.WriteSuccess; they now render plain viaIRenderer.Info. No JSON-mode or stderr behaviour change.Test plan
dotnet test SafeCommands.slnx— 133/133 passsafe git status(smoke — real git invocation works)safe git push --force --json→ structured Blocked envelopesafe git push --force→ Spectre markup (unchanged human mode)safe git add .→ blocked, same wordingsafe env vars --json→ unmigrated group still emits its existing JSON shape (no regression)🤖 Generated with Claude Code