Skip to content

refactor: migrate git+file groups to ports with IGitRepo (PR 3/6 of #2)#5

Open
stevehansen wants to merge 2 commits intorefactor/issue-2-phase-3-flag-block-groupsfrom
refactor/issue-2-phase-4-git-file
Open

refactor: migrate git+file groups to ports with IGitRepo (PR 3/6 of #2)#5
stevehansen wants to merge 2 commits intorefactor/issue-2-phase-3-flag-block-groupsfrom
refactor/issue-2-phase-4-git-file

Conversation

@stevehansen
Copy link
Copy Markdown
Member

Summary

Phase 4 of #2 — migrate GitCommands and FileCommands to ports, introducing the IGitRepo port that earns its keep by deduplicating git state probes.

Stacked on #4 (Phase 3). Targets the Phase 3 branch; will rebase to main once #4 merges.

What's new

  • IGitRepo port with five structured probes — IsRepo, IsWorkingTreeClean, IsFileTracked, HasPendingChanges, GetHeadStatus. Each is answerable by a single git invocation (per the RFC contract).
  • GitRepoAdapter real implementation; FakeGitRepo fluent-builder fake (AsRepo, WithCleanTree, WithTracked, WithPushedHead, etc.).
  • Ports record now carries a third port: Ports(IExecutor Exec, IRenderer Render, IGitRepo Git).
  • GitCommands / FileCommands migrated to Func<Ports, string[], int>. The three rev-parse calls in RunCommitAmend collapse to one GetHeadStatus(); the three git invocations in RunDeleteTracked collapse to IsFileTracked + HasPendingChanges.
  • Structured Blocked envelope under --json extends to two more groups (now 8 of 12 migrated). The remaining four — env, process, proxy, generate — still emit Spectre markup until later phases.

Tests

  • 37 new tests (GitCommandsTests, FileCommandsTests); total 133.
  • The previously-impossible assertion is now trivial:
    git.AsRepo().WithPushedHead("main", "origin/main");
    Assert.Equal(1, GitCommands.RunCommitAmend(ports, ["-m", "tweak"]));
    Assert.Empty(exec.Calls);  // never spawned git
  • FileCommandsTests mutates process-wide cwd, so it lives in a [Collection] with parallelization disabled to avoid clobbering peer tests.

Behaviour notes

  • All safe git ... and safe file ... invocations behave identically in human mode. JSON-mode blocked output now follows the structured envelope shape introduced in 0.4.0.
  • One cosmetic change: safe file mkdir|copy|write|move|delete-tracked success messages used to render in green via OutputFormatter.WriteSuccess; they now render plain via IRenderer.Info. No JSON-mode or stderr behaviour change.

Test plan

  • dotnet test SafeCommands.slnx — 133/133 pass
  • safe git status (smoke — real git invocation works)
  • safe git push --force --json → structured Blocked envelope
  • safe git push --force → Spectre markup (unchanged human mode)
  • safe git add . → blocked, same wording
  • safe env vars --json → unmigrated group still emits its existing JSON shape (no regression)

🤖 Generated with Claude Code

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>
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +39 to +55
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);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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);
    }

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +31 to +37
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);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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);
    }

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/&lt;branch&gt;</c>. <see cref="HeadStatus.IsPushed"/>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The documentation mentions origin/ explicitly, but the implementation should support any upstream tracking branch. Updating the comment to be more generic avoids confusion.

    /// Status of HEAD relative to its upstream tracking branch. <see cref="HeadStatus.IsPushed"/>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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".

Comment on lines +301 to +302
var offending = args.FirstOrDefault(a =>
a is "--force" or "-f" or "--delete" or "--no-verify") ?? "";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant