Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Purpose: Enable AI agents to quickly understand and extend the `stack` CLI (bran
- **Entry**: `Program.cs` builds DI host then invokes `StackRootCommand` (System.CommandLine pattern).
- **Command Structure**: Each feature = command class inheriting `Infrastructure/Commands/Command.cs` (adds common options, logging, error handling, input prompts). Command handlers are split: command class parses & constructs handler (e.g. `NewStackCommand` + `NewStackCommandHandler`). Follow this separation religiously.
- **Core Domains**:
1. **Config (`Config/`)**: Persistent model (`Stack`, `Branch`) with schema migration v1→v2 in `FileStackConfig`. Config path is `%AppData%/stack/config.json` (Windows). v1 = linear list, v2 = tree structure. Backward compatibility is critical—use existing mapping helpers. **Stack Repository Pattern**: Command handlers access stacks through `IStackRepository` (scoped service) instead of `IStackConfig` directly. Repository automatically filters stacks by current git remote URI—handlers don't need to know about remote filtering. See "Stack Repository Pattern" section below.
1. **Config (`Config/`)**: Persistent model (`Stack`, `Branch`) with schema migration v1→v2 in `FileStackDataStore`. Config path is `%AppData%/stack/config.json` (Windows). v1 = linear list, v2 = tree structure. Backward compatibility is critical—use existing mapping helpers. **Stack Repository Pattern**: Command handlers access stacks through `IStackRepository` (scoped service) instead of `IStackConfig` directly. Repository automatically filters stacks by current git remote URI—handlers don't need to know about remote filtering. See "Stack Repository Pattern" section below.
2. **Git (`Git/`)**: Thin wrapper over `git` & `gh` CLIs via `ProcessHelpers`. Never re-implement git porcelain—compose existing commands. Conflict detection surfaces as `ConflictException`.
3. **Stack orchestration (`Commands/Helpers/StackActions.cs` & `StackHelpers.cs`)**: Implements update strategies (merge vs rebase), batch operations, PR list maintenance, status tree rendering (Spectre.Console). Pull request status lookups are best-effort and may be skipped if the GitHub CLI is unavailable.
4. **GitHub integration (`GitHubClient`, `SafeGitHubClient`, `CachingGitHubClient`)**: Uses `gh pr` CLI JSON output. `SafeGitHubClient` swallows failures (missing CLI, auth, network) for status lookups only (`GetPullRequest`) and logs a single warning, ensuring commands still succeed; create/edit/open operations still propagate errors. Extend by adding fields to the source gen context.
Expand Down Expand Up @@ -38,7 +38,7 @@ Purpose: Enable AI agents to quickly understand and extend the `stack` CLI (bran
1. **Create Command Class**: `Commands/<Area>/<Name>Command.cs` inheriting `Command` base class.
2. **Define Options**: Define any new `Option<T>` locally; reuse existing ones from `CommonOptions` static properties.
3. **Wire Dependencies**: In constructor, `Add(...)` options and set description; register in `StackRootCommand` constructor.
4. **Implement Execute**: Override `Execute` method. Inject `IStackRepository` (instead of `IStackConfig`), `IGitClientFactory`, and `CliExecutionContext` via handler constructor.
4. **Implement Execute**: Override `Execute` method. Inject `IStackRepository` (instead of `IStackDataStore`), `IGitClientFactory`, and `CliExecutionContext` via handler constructor.
5. **Separate Handler Logic**: Put non-trivial business logic in separate handler class (suffix `CommandHandler`) inheriting `CommandHandlerBase<TInput>` to keep command class thin and testable.
6. **Exception Handling**: Follow existing patterns—let `ProcessException` bubble for standardized error output via base `Command` class.

Expand Down
22 changes: 11 additions & 11 deletions src/Stack.Tests/Commands/Branch/AddBranchCommandHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ public async Task WhenNoInputsProvided_AsksForStackAndBranchAndParentBranchAndCo
// Assert
stackRepository.Stacks.Should().BeEquivalentTo(new List<Model.Stack>
{
new("Stack1", stackRepository.RemoteUri, sourceBranch, [new Model.Branch(firstBranch, [new Model.Branch(childBranch, []), new Model.Branch(branchToAdd, [])])]),
new("Stack2", stackRepository.RemoteUri, sourceBranch, [])
new("Stack1", sourceBranch, [new Model.Branch(firstBranch, [new Model.Branch(childBranch, []), new Model.Branch(branchToAdd, [])])]),
new("Stack2", sourceBranch, [])
});
}

Expand Down Expand Up @@ -96,8 +96,8 @@ public async Task WhenStackNameProvided_DoesNotAskForStackName_AddsBranchFromSta
await inputProvider.DidNotReceive().Select(Questions.SelectStack, Arg.Any<string[]>(), Arg.Any<CancellationToken>());
stackRepository.Stacks.Should().BeEquivalentTo(new List<Model.Stack>
{
new("Stack1", stackRepository.RemoteUri, sourceBranch, [new Model.Branch(anotherBranch, [new Model.Branch(branchToAdd, [])])]),
new("Stack2", stackRepository.RemoteUri, sourceBranch, [])
new("Stack1", sourceBranch, [new Model.Branch(anotherBranch, [new Model.Branch(branchToAdd, [])])]),
new("Stack2", sourceBranch, [])
});
}

Expand Down Expand Up @@ -137,7 +137,7 @@ public async Task WhenOnlyOneStackExists_DoesNotAskForStackName_AddsBranchFromSt
await inputProvider.DidNotReceive().Select(Questions.SelectStack, Arg.Any<string[]>(), Arg.Any<CancellationToken>());
stackRepository.Stacks.Should().BeEquivalentTo(new List<Model.Stack>
{
new("Stack1", stackRepository.RemoteUri, sourceBranch, [new Model.Branch(anotherBranch, [new Model.Branch(branchToAdd, [])])])
new("Stack1", sourceBranch, [new Model.Branch(anotherBranch, [new Model.Branch(branchToAdd, [])])])
});
}

Expand Down Expand Up @@ -212,8 +212,8 @@ public async Task WhenBranchNameProvided_DoesNotAskForBranchName_AddsBranchFromS
// Assert
stackRepository.Stacks.Should().BeEquivalentTo(new List<Model.Stack>
{
new("Stack1", stackRepository.RemoteUri, sourceBranch, [new Model.Branch(anotherBranch, [new Model.Branch(branchToAdd, [])])]),
new("Stack2", stackRepository.RemoteUri, sourceBranch, [])
new("Stack1", sourceBranch, [new Model.Branch(anotherBranch, [new Model.Branch(branchToAdd, [])])]),
new("Stack2", sourceBranch, [])
});
await inputProvider.DidNotReceive().Select(Questions.SelectBranch, Arg.Any<string[]>(), Arg.Any<CancellationToken>());
}
Expand Down Expand Up @@ -327,8 +327,8 @@ public async Task WhenAllInputsProvided_DoesNotAskForAnything_AddsBranchFromStac
// Assert
stackRepository.Stacks.Should().BeEquivalentTo(new List<Model.Stack>
{
new("Stack1", stackRepository.RemoteUri, sourceBranch, [new Model.Branch(anotherBranch, [new Model.Branch(branchToAdd, [])])]),
new("Stack2", stackRepository.RemoteUri, sourceBranch, [])
new("Stack1", sourceBranch, [new Model.Branch(anotherBranch, [new Model.Branch(branchToAdd, [])])]),
new("Stack2", sourceBranch, [])
});
inputProvider.ReceivedCalls().Should().BeEmpty();
}
Expand Down Expand Up @@ -372,8 +372,8 @@ public async Task WhenParentBranchProvided_DoesNotAskForParentBranch_CreatesNewB
// Assert
stackRepository.Stacks.Should().BeEquivalentTo(new List<Model.Stack>
{
new("Stack1", stackRepository.RemoteUri, sourceBranch, [new Model.Branch(firstBranch, [new Model.Branch(childBranch, []), new Model.Branch(branchToAdd, [])])]),
new("Stack2", stackRepository.RemoteUri, sourceBranch, [])
new("Stack1", sourceBranch, [new Model.Branch(firstBranch, [new Model.Branch(childBranch, []), new Model.Branch(branchToAdd, [])])]),
new("Stack2", sourceBranch, [])
});

await inputProvider.DidNotReceive().Select(Questions.SelectParentBranch, Arg.Any<string[]>(), Arg.Any<CancellationToken>());
Expand Down
10 changes: 5 additions & 5 deletions src/Stack.Tests/Commands/Branch/MoveBranchCommandHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ public async Task WhenMovingBranchWithoutChildren_MovesBranchToNewParent()
// Assert
stackRepository.Stacks.Should().BeEquivalentTo(new List<Model.Stack>
{
new("Stack1", stackRepository.RemoteUri, sourceBranch, [
new("Stack1", sourceBranch, [
new Model.Branch(firstBranch, [new Model.Branch(branchToMove, [])]),
new Model.Branch(secondBranch, [])
])
Expand Down Expand Up @@ -100,7 +100,7 @@ public async Task WhenMovingBranchWithChildren_AndMoveChildrenOption_MovesBranch
// Assert
stackRepository.Stacks.Should().BeEquivalentTo(new List<Model.Stack>
{
new("Stack1", stackRepository.RemoteUri, sourceBranch, [
new("Stack1", sourceBranch, [
new Model.Branch(firstBranch, [new Model.Branch(branchToMove, [new Model.Branch(childBranch, [])])])
])
});
Expand Down Expand Up @@ -147,7 +147,7 @@ public async Task WhenMovingBranchWithChildren_AndReParentChildrenOption_MovesBr
// Assert
stackRepository.Stacks.Should().BeEquivalentTo(new List<Model.Stack>
{
new("Stack1", stackRepository.RemoteUri, sourceBranch, [
new("Stack1", sourceBranch, [
new Model.Branch(firstBranch, [new Model.Branch(branchToMove, [])]),
new Model.Branch(childBranch, [])
])
Expand Down Expand Up @@ -191,7 +191,7 @@ public async Task WhenMovingBranchToSourceBranch_MovesBranchToRootLevel()
// Assert
stackRepository.Stacks.Should().BeEquivalentTo(new List<Model.Stack>
{
new("Stack1", stackRepository.RemoteUri, sourceBranch, [
new("Stack1", sourceBranch, [
new Model.Branch(firstBranch, []),
new Model.Branch(branchToMove, [])
])
Expand Down Expand Up @@ -231,7 +231,7 @@ public async Task WhenAllInputsProvided_DoesNotPromptUser()
// Assert
stackRepository.Stacks.Should().BeEquivalentTo(new List<Model.Stack>
{
new("Stack1", stackRepository.RemoteUri, sourceBranch, [
new("Stack1", sourceBranch, [
new Model.Branch(firstBranch, [new Model.Branch(branchToMove, [])])
])
});
Expand Down
26 changes: 13 additions & 13 deletions src/Stack.Tests/Commands/Branch/NewBranchCommandHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ public async Task WhenNoInputsProvided_AsksForStackAndBranchAndParentBranch_Crea
// Assert
stackRepository.Stacks.Should().BeEquivalentTo(new List<Model.Stack>
{
new("Stack1", stackRepository.RemoteUri, sourceBranch, [new Model.Branch(firstBranch, [new Model.Branch(childBranch, []), new Model.Branch(newBranch, [])])]),
new("Stack2", stackRepository.RemoteUri, sourceBranch, [])
new("Stack1", sourceBranch, [new Model.Branch(firstBranch, [new Model.Branch(childBranch, []), new Model.Branch(newBranch, [])])]),
new("Stack2", sourceBranch, [])
});
gitClient.Received().CreateNewBranch(newBranch, firstBranch);
gitClient.Received().ChangeBranch(newBranch);
Expand Down Expand Up @@ -99,8 +99,8 @@ public async Task WhenStackNameProvided_DoesNotAskForStackName_CreatesAndAddsBra
await inputProvider.DidNotReceive().Select(Questions.SelectStack, Arg.Any<string[]>(), Arg.Any<CancellationToken>());
stackRepository.Stacks.Should().BeEquivalentTo(new List<Model.Stack>
{
new("Stack1", stackRepository.RemoteUri, sourceBranch, [new Model.Branch(anotherBranch, [new Model.Branch(newBranch, [])])]),
new("Stack2", stackRepository.RemoteUri, sourceBranch, [])
new("Stack1", sourceBranch, [new Model.Branch(anotherBranch, [new Model.Branch(newBranch, [])])]),
new("Stack2", sourceBranch, [])
});
}

Expand Down Expand Up @@ -140,7 +140,7 @@ public async Task WhenOnlyOneStackExists_DoesNotAskForStackName_CreatesAndAddsBr
await inputProvider.DidNotReceive().Select(Questions.SelectStack, Arg.Any<string[]>(), Arg.Any<CancellationToken>());
stackRepository.Stacks.Should().BeEquivalentTo(new List<Model.Stack>
{
new("Stack1", stackRepository.RemoteUri, sourceBranch, [new Model.Branch(anotherBranch, [new Model.Branch(newBranch, [])])]),
new("Stack1", sourceBranch, [new Model.Branch(anotherBranch, [new Model.Branch(newBranch, [])])]),
});
}

Expand Down Expand Up @@ -218,8 +218,8 @@ public async Task WhenBranchNameProvided_DoesNotAskForBranchName_CreatesAndAddsB
// Assert
stackRepository.Stacks.Should().BeEquivalentTo(new List<Model.Stack>
{
new("Stack1", stackRepository.RemoteUri, sourceBranch, [new Model.Branch(anotherBranch, [new Model.Branch(newBranch, [])])]),
new("Stack2", stackRepository.RemoteUri, sourceBranch, [])
new("Stack1", sourceBranch, [new Model.Branch(anotherBranch, [new Model.Branch(newBranch, [])])]),
new("Stack2", sourceBranch, [])
});
await inputProvider.DidNotReceive().Text(Questions.BranchName, Arg.Any<CancellationToken>(), Arg.Any<string>());
}
Expand Down Expand Up @@ -341,8 +341,8 @@ public async Task WhenPushToTheRemoteFails_StillCreatesTheBranchLocallyAndAddsIt
// Assert
stackRepository.Stacks.Should().BeEquivalentTo(new List<Model.Stack>
{
new("Stack1", stackRepository.RemoteUri, sourceBranch, [new Model.Branch(anotherBranch, [new Model.Branch(newBranch, [])])]),
new("Stack2", stackRepository.RemoteUri, sourceBranch, [])
new("Stack1", sourceBranch, [new Model.Branch(anotherBranch, [new Model.Branch(newBranch, [])])]),
new("Stack2", sourceBranch, [])
});
gitClient.Received().CreateNewBranch(newBranch, anotherBranch);
gitClient.Received().ChangeBranch(newBranch);
Expand Down Expand Up @@ -388,8 +388,8 @@ public async Task WhenParentBranchNotProvided_AsksForParentBranch_CreatesNewBran
// Assert
stackRepository.Stacks.Should().BeEquivalentTo(new List<Model.Stack>
{
new("Stack1", stackRepository.RemoteUri, sourceBranch, [new Model.Branch(firstBranch, [new Model.Branch(childBranch, []), new Model.Branch(newBranch, [])])]),
new("Stack2", stackRepository.RemoteUri, sourceBranch, [])
new("Stack1", sourceBranch, [new Model.Branch(firstBranch, [new Model.Branch(childBranch, []), new Model.Branch(newBranch, [])])]),
new("Stack2", sourceBranch, [])
});
gitClient.Received().CreateNewBranch(newBranch, firstBranch);
gitClient.Received().ChangeBranch(newBranch);
Expand Down Expand Up @@ -434,8 +434,8 @@ public async Task WhenParentBranchProvided_DoesNotAskForParentBranch_CreatesNewB
// Assert
stackRepository.Stacks.Should().BeEquivalentTo(new List<Model.Stack>
{
new("Stack1", stackRepository.RemoteUri, sourceBranch, [new Model.Branch(firstBranch, [new Model.Branch(childBranch, []), new Model.Branch(newBranch, [])])]),
new("Stack2", stackRepository.RemoteUri, sourceBranch, [])
new("Stack1", sourceBranch, [new Model.Branch(firstBranch, [new Model.Branch(childBranch, []), new Model.Branch(newBranch, [])])]),
new("Stack2", sourceBranch, [])
});
gitClient.Received().CreateNewBranch(newBranch, firstBranch);
gitClient.Received().ChangeBranch(newBranch);
Expand Down
Loading