Skip to content

Conversation

@yotsuda
Copy link
Contributor

@yotsuda yotsuda commented Nov 19, 2025

PR Summary

This PR adds a new -Extension parameter to Join-Path cmdlet that allows changing the file extension of the resulting path, providing parity with Split-Path's extension handling capabilities.

Fixes #20724

PR Context

The Split-Path cmdlet has an -Extension parameter that retrieves the file extension from a path. However, Join-Path lacks the ability to set or change extensions when joining paths. This enhancement adds an -Extension string parameter to Join-Path that modifies the extension of the final joined path.

Changes

  • Modified CombinePathCommand.cs: Added -Extension parameter with ValueFromPipelineByPropertyName support
  • Uses System.IO.Path.ChangeExtension() to modify the extension
  • Handles empty string extension by removing the trailing dot for cleaner output
  • Added 6 test cases in Join-Path.Tests.ps1

Testing

New tests added:

  1. "should change extension when -Extension parameter is specified"
  2. "should add extension to file without extension"
  3. "should remove extension when empty string is specified"
  4. "should change extension for multiple paths"
  5. "should handle extension with or without leading dot"
  6. "should accept Extension from pipeline by property name"

Examples:

# Change extension
Join-Path -Path "C:\folder" -ChildPath "file.txt" -Extension ".log"
# Output: C:\folder\file.log

# Add extension
Join-Path -Path "C:\folder" -ChildPath "file" -Extension ".txt"
# Output: C:\folder\file.txt

# Remove extension
Join-Path -Path "C:\folder" -ChildPath "file.txt" -Extension ""
# Output: C:\folder\file

# Pipeline input
[PSCustomObject]@{ Path = "C:\folder"; ChildPath = "file.txt"; Extension = ".log" } | Join-Path
# Output: C:\folder\file.log

Test results:

  • All 6 new tests pass
  • All existing tests continue to pass

PR Checklist

This adds a new -Extension parameter to Join-Path that allows changing
the file extension of the resulting path, providing parity with
Split-Path's extension handling capabilities.

Features:
- Change extension: Join-Path -Path C:\folder -ChildPath file.txt -Extension .log
- Add extension: Join-Path -Path C:\folder -ChildPath file -Extension .txt
- Remove extension: Join-Path -Path C:\folder -ChildPath file.txt -Extension ''
- Supports pipeline input via ValueFromPipelineByPropertyName

Fixes PowerShell#20724
@iSazonov iSazonov requested a review from Copilot November 19, 2025 05:55
@iSazonov iSazonov added the CL-General Indicates that a PR should be marked as a general cmdlet change in the Change Log label Nov 19, 2025
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

This PR adds a new -Extension parameter to the Join-Path cmdlet, enabling users to modify file extensions when joining paths. This provides parity with Split-Path's extension handling and eliminates the need for separate extension manipulation after path joining.

Key changes:

  • Added -Extension parameter with pipeline by property name support to JoinPathCommand
  • Implemented extension modification using System.IO.Path.ChangeExtension with special handling for empty string extensions
  • Added 6 test cases covering basic extension operations and pipeline input

Reviewed Changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 7 comments.

File Description
src/Microsoft.PowerShell.Commands.Management/commands/management/CombinePathCommand.cs Adds the -Extension parameter property and implements the extension change logic in ProcessRecord() after path joining but before resolution
test/powershell/Modules/Microsoft.PowerShell.Management/Join-Path.Tests.ps1 Adds 6 test cases covering extension change, addition, removal, multiple paths, dot handling, and pipeline input

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

yotsuda and others added 4 commits November 19, 2025 16:38
- Use string.TrimEnd('.') instead of Substring for cleaner code
- Remove redundant EndsWith check
- Add tests for multiple dots, directory-like paths, and multiple child paths
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

yotsuda and others added 2 commits November 20, 2025 12:16
Use $SepChar variable instead of hardcoded backslashes in test
expectations to ensure tests pass on both Windows and Unix platforms.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@iSazonov
Copy link
Collaborator

@brianary Please review.

@iSazonov
Copy link
Collaborator

iSazonov commented Nov 20, 2025

@yotsuda Please open new issue in https://github.com/MicrosoftDocs/PowerShell-Docs

GitHub
The official PowerShell documentation sources. Contribute to MicrosoftDocs/PowerShell-Docs development by creating an account on GitHub.

Comment on lines 149 to 153
// Remove trailing dot when extension is empty string
if (Extension.Length == 0)
{
joinedPath = joinedPath.TrimEnd('.');
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

@mklement0 What is your opinion about this?

@yotsuda Have we test for the code path?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@iSazonov Yes, this code path is tested at line 95-97 in Join-Path.Tests.ps1.

However, I'm considering adding another test case to document the behavior of TrimEnd('.') with multiple trailing dots (e.g., file..txt → file). This would clarify the intentional difference from the original Substring approach.

Should I add this test, or is the current coverage sufficient?

Copy link
Collaborator

Choose a reason for hiding this comment

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

@yotsuda Lets wait @mklement0 feedback.

Copy link
Contributor

Choose a reason for hiding this comment

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

Technically, it amounts to a deviation from the underlying .NET API, because [io.path]::ChangeExtension('x.txt', '') yields 't.', i.e. retains the trailing .; by contrast, the trailing . is removed if you pass a true null ([io.path]::ChangeExtension('x.txt', [nullstring]::value)).

That said, making a '' argument exhibit the latter behavior - i.e. removal of the (last) extension - makes more sense to me, not least because you cannot really files with names that end in . on Windows (the trailing . is in effect ignored; not on Unix-like platforms, though).

The simpler and more robust implementation, though, is to simply translate the empty string to null and let .ChangeExtension() deal with it - it only removes the last extension present, and each lone . is considered its own extension; e.g. [io.path]::ChangeExtension('x...', [nullstring]::value) yields 'x..'.

Copy link
Contributor

Choose a reason for hiding this comment

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

Note that if we wanted to be consistent with .NET, users would have to - awkwardly - pass ([NullString]::Value) as the -Extension argument in order to achieve extension removal.

Copy link
Collaborator

Choose a reason for hiding this comment

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

-Extension [NullString]::Value effectively ignore the parameter.

Copy link
Contributor

@mklement0 mklement0 Nov 20, 2025

Choose a reason for hiding this comment

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

-Extension [NullString]::Value would pass verbatim [NullString]::Value as the parameter value, so I assume you mean -Extension ([NullString]::Value)
Yes, the current code ignores a null value, so IF we wanted to support that, we'd have to modify that - but I don't think we should.

To summarize what I'm suggesting:

  • Prevent passing null (both [NullString]::Value and $null) as the parameter value, which is consistent with what most cmdlets with [string] parameters do.

  • Translate "" as an argument value to null when calling .ChangeExtension(), resulting in removal of the (last) extension.

  • Document that "." must be passed in order to retain the (last) trailing .

Copy link
Contributor

@mklement0 mklement0 Nov 20, 2025

Choose a reason for hiding this comment

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

To spell it out:

Inside the if (Extension != null && joinedPath != null) branch, the only code should be:

joinedPath = System.IO.Path.ChangeExtension(joinedPath, Extension.Length == 0 ? null : Extension);

Plus, the Extension parameter property must be declared not to accept null, though I'm confused about that: I thought that is the case by default and that you must conversely opt in to support null, via [AllowNull()]. However, in quick tests it seems that null is accepted by default. So perhaps a [ValidateNotNull] attribute is needed too.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@mklement0 Thank you for the review! I've simplified the code as suggested in 9b084f2:

joinedPath = System.IO.Path.ChangeExtension(joinedPath, Extension.Length == 0 ? null : Extension);

Question about [ValidateNotNull]: When -Extension "" is specified, we internally pass null to ChangeExtension(). However, this is unrelated to the behavior when -Extension $null is passed (or when -Extension is not specified at all). Therefore, the [ValidateNotNull] attribute doesn't seem necessary, but please let me know if it is indeed required.

I've also added test cases for preserving dots in base names ("file...txt""file.." when removing, "file...txt""file...md" when changing).

Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks, @yotsuda.

The goal is to ensure that neither -Extension $null nor -Extension ([NullString]::Value) are accepted.

If this is already the case: great.
If not, [ValidateNotNull] is needed.

(As noted, I'm unclear on whether the non-null constraint is enforced by default or not.)

Copy link
Contributor

@brianary brianary left a comment

Choose a reason for hiding this comment

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

Looks great!

@kilasuit
Copy link
Collaborator

Minor brief comment from inital look at this PR's intent.

I feel there's no real need for the . to be passed in the extension as logically I'd expect that if you are passing -extension ".txt" for the actual extension to be ..txt or a more common scenario -extension "tar.gz" for a TAR compressed archive with gz like myarchive.tar.gz but we should ensure this PR allows people to have code that allows for both -extension "tar.gz" & -extension ".tar.gz" to work with no unintended consequences & documented that we do this purposefully to protect from accidental ..tar.gz extensions and the like.

So if that scenario works too, I'll be more than happy with this being added.

@kilasuit kilasuit added the WG-Cmdlets-Management cmdlets in the Microsoft.PowerShell.Management module label Nov 20, 2025
@mklement0
Copy link
Contributor

@kilasuit, yes, a leading . is tolerated by the underlying .NET API, so that -Extension .tar.gz and -Extension tar.gz can be used interchangeably; e.g., both [io.path]::ChangeExtension('x.txt', 'tar.gz') and [io.path]::ChangeExtension('x.txt', '.tar.gz') yield 'x.tar.gz'

- Simplify extension logic: use ternary operator to pass null when empty
- Remove TrimEnd('.'): fixes issue where dots in base name were removed
- Add test cases for preserving dots in base name:
  * When removing extension (file...txt -> file..)
  * When changing extension (file...txt -> file...md)
Comment on lines 87 to 94
It "should change extension when -Extension parameter is specified" {
$result = Join-Path -Path "folder" -ChildPath "file.txt" -Extension ".log"
$result | Should -BeExactly "folder${SepChar}file.log"
}
It "should add extension to file without extension" {
$result = Join-Path -Path "folder" -ChildPath "file" -Extension ".txt"
$result | Should -BeExactly "folder${SepChar}file.txt"
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

We can use TestCases for all tests.

Also I'd add cases - extension without dot ("txt"), double extension (".tar.gz", "tar.gz")

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thank you for the review! I've refactored the tests to use TestCases and added the requested test cases for extension without dot ("txt") and double extensions (".tar.gz", "tar.gz") in f06c5bc.
All tests pass.

yotsuda and others added 3 commits November 21, 2025 23:05
- Consolidate individual extension tests into TestCases for better maintainability
- Add test cases for extension without leading dot ('txt')
- Add test cases for double extensions ('.tar.gz', 'tar.gz')
- Remove duplicate test that is now covered by TestCases

Addresses review feedback from @iSazonov
- Add [ValidateNotNull] attribute to prevent explicit null values
- Update XML comment to clarify default behavior when Extension is not specified

Addresses review feedback from @iSazonov
Path = "folder"
ChildPath = "file.txt"
Extension = ".log"
ExpectedResult = "folder${SepChar}file.log"
Copy link
Collaborator

Choose a reason for hiding this comment

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

For readability I'd simplify these cases.
Path is the same in all tests so I believe we can remove it here and use directly in test body.
ExpectedResult I'd rename to ExpectedChildPath, leave only file name with new extension and build the full value in test body.:

Suggested change
ExpectedResult = "folder${SepChar}file.log"
ExpectedChildPath = "file.log"

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@iSazonov Updated in 6096b84. Simplified TestCases as suggested - removed constant Path, renamed ExpectedResult to ExpectedChildPath, and build full path in test body. Thank you!

- Remove Path from TestCases as it's constant across all tests
- Rename ExpectedResult to ExpectedChildPath for clarity
- Build full path in test body using 'folder' directly

Addresses review feedback from @iSazonov
@yotsuda
Copy link
Contributor Author

yotsuda commented Nov 22, 2025

@iSazonov Thank you for the guidance. I've opened an issue in PowerShell-Docs to request documentation for the new -Extension parameter:
MicrosoftDocs/PowerShell-Docs#12535

@iSazonov iSazonov added the PowerShell-Docs needed The PR was reviewed and a PowerShell Docs update is needed label Nov 22, 2025
Comment on lines 123 to 130
It "should remove extension when empty string is specified" {
$result = Join-Path -Path "folder" -ChildPath "file.txt" -Extension ""
$result | Should -BeExactly "folder${SepChar}file"
}
It "should preserve dots in base name when removing extension with empty string" {
$result = Join-Path -Path "folder" -ChildPath "file...txt" -Extension ""
$result | Should -BeExactly "folder${SepChar}file.."
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

The tests can be in TestCases too. Below there are more.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@iSazonov Thank you for the feedback! I've consolidated six individual tests into TestCases arrays in 11ff363.

- Consolidate six individual tests into TestCases arrays
- Tests for removing/changing extensions are now part of parameterized tests
- Created separate TestCases group for multiple child path segments
- This improves consistency and maintainability

Addresses review feedback from @iSazonov
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@iSazonov iSazonov merged commit 70a6a80 into PowerShell:master Nov 22, 2025
41 of 42 checks passed
@iSazonov iSazonov self-assigned this Nov 22, 2025
@yotsuda yotsuda deleted the feature/join-path-extension branch November 22, 2025 13:52
@adityapatwardhan
Copy link
Member

The PowerShell Maintainer's discussed this PR and we have decided to not take this in v7.6. This will be part of the next v7.7 preview.

SIRMARGIN pushed a commit to SIRMARGIN/PowerShell that referenced this pull request Dec 12, 2025
kilasuit pushed a commit to kilasuit/PowerShell that referenced this pull request Jan 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CL-General Indicates that a PR should be marked as a general cmdlet change in the Change Log PowerShell-Docs needed The PR was reviewed and a PowerShell Docs update is needed WG-Cmdlets general cmdlet issues WG-Cmdlets-Management cmdlets in the Microsoft.PowerShell.Management module WG-NeedsReview Needs a review by the labeled Working Group

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Join-Path -Extension <string> parameter

6 participants