Streaming File History
In this issue:
- Getting Content
- Content Options
- Adding a Stream
- Getting Stream Data
- Updating a Stream
- Helper Functions
- Restoring History
- Cleanup and Removal
- Change Events
- Summary
I hope you have had a chance to try my code examples from recent articles on using alternate data streams. I want to return to the topic and explore how we might use alternate data streams to maintain a history of changes to a file over time. My thought is go beyond traditional change control tools like git and instead save previous file versions directly within the file itself using alternate data streams. Does that sound interesting?
I'll use a single file to test this idea.
$path = 'C:\temp\sample-script.ps1'
Getting Content
It is trivial to get the current contents of the file.
$c= Get-Content -path $path
But I can't simply write that back to an alternate data stream. If I am going to have a history, presumably I want a way to identify each version. I can do that by adding a timestamp.
$a = [PSCustomObject]@{
Date = (Get-Date)
Path = Convert-Path $path
Content = (Get-Content -Path $path)
}
This gives me a structured object that contains the date, path, and content of the file.
PS C:\> $a
Date Path Content
---- ---- -------
1/20/2026 11:49:37 AM C:\temp\sample-script.ps1 {#requires -version 5.1, #requi…
However, I need to serialize that object so that I can store it in an alternate data stream. One way is to convert it to json
PS C:\> $a | ConvertTo-Json
{
"Date": "2026-01-20T11:49:37.8031812-05:00",
"Path": "C:\\temp\\sample-script.ps1",
"Content": [
"#requires -version 5.1",
"#requires -RunAsAdministrator",
"",
"<#",
"Sample-Script.ps1",
...
]
}
Content Options
I actually have a few options for getting and storing the content. My assumption is that I primarily want to capture file history for my script files. Since these are text files, I can store the content as an array of strings (as shown above) or as a single string. Although I could use Get-Content and save the data as an array of bytes.
$bs = Get-Content $path -AsByteStream
But this requires a little more work to get it back to a human readable form later. For now, I'll stick with the array of strings.
[text.encoding]::UTF8.GetString($bs)
But, if you decide to use my ideas to track binary files, you'll need to use the byte stream approach.
It is also possible to get the file contents from the :$DATA stream directly. I'm not 100% sure on the I/O implications, but from my limited testing, getting the content from the :$DATA stream seems to be faster than using Get-Content.
Get-Content -Path $path -Stream ':$DATA'
For my purposes, I am going to use this approach as I think it might also avoid potential file locking issues.
$a = [PSCustomObject]@{
Date = (Get-Date)
Path = Convert-Path $path
Content = (Get-Content -Path $path -Stream ':$DATA')
}
Adding a Stream
As I've shown in past articles, it is easy to add an alternate data stream to a file. I just need to specify the stream name. I'm going to call my stream history.
Set-Content -Path $path -Stream history -Value ($a | ConvertTo-Json)
The content needs to be simple like strings or bytes.
Getting Stream Data
To retrieve the data from the stream, I can use Get-Content again.
PS C:\> Get-Content -Path $path -Stream history | ConvertFrom-Json
Date Path Content
---- ---- -------
1/20/2026 12:01:33 PM C:\temp\sample-script.ps1 {#requires -version 5.1, #requires -RunAsAdmini…}
Because the raw stream is JSON text, I need to convert it back to an object using ConvertFrom-Json.
Updating a Stream
When using Set-Content, the operation will overwrite any existing stream with the same name. To maintain a history, I need to first get the existing history, add the new entry, and then write it back.
[object[]]$h = Get-Content -Path $path -Stream history | ConvertFrom-Json
$h += [PSCustomObject]@{
Date = (Get-Date)
Path = Convert-Path $path
Content = (Get-Content -Path $path -Stream ':$DATA')
}
I now have an array of history objects.
PS C:\> $h.Date
Tuesday, January 20, 2026 12:01:33 PM
Tuesday, January 20, 2026 12:08:25 PM
I can write this back to the stream.
Set-Content -Path $path -Stream history -Value ($h | ConvertTo-Json)
What About Add-Content?
Savvy readers may wonder why I am not using Add-Content to append to the stream. The reason is that Add-Content appends raw text to the end of the stream. Since I am storing JSON objects, I can't simply append another JSON object to the end of the stream. The entire stream content must be a single valid JSON structure. This isn't to say that you can't use Add-Content, but it will depend on how you plan to structure the data in the stream.
Helper Functions
So far I am using PowerShell expressions to manage the history stream.
PS C:\> Get-Content -Path $path -Stream history | ConvertFrom-Json | Format-Table -GroupBy Path -Property Date,Content
Path: C:\temp\sample-script.ps1
Date Content
---- -------
1/20/2026 12:01:33 PM {#requires -version 5.1, #requires -RunAsAdministrator, , <#…}
1/20/2026 12:08:25 PM {#requires -version 5.1, #requires -RunAsAdministrator, , <#…}
It would be easier to use PowerShell functions.
Get-HistoryStream
In my functions, I want to make sure I am using file system paths and not rely on PSDrive references, so I will use Convert-Path. Here is my function to get the history stream.
function Get-HistoryStream {
[cmdletbinding()]
param(
[Parameter(
Mandatory,
ValueFromPipeline,
ValueFromPipelineByPropertyName,
HelpMessage = 'Specify the file name and path'
)]
[ValidateScript({ Test-Path -LiteralPath $_ })]
[string]$Path,
[ValidateNotNullOrEmpty()]
[string]$Stream = 'history'
)
begin {
Write-Verbose "[$((Get-Date).TimeOfDay) BEGIN ] Starting $($MyInvocation.MyCommand)"
Write-Verbose "[$((Get-Date).TimeOfDay) BEGIN ] Running in PowerShell v$($PSVersionTable.PSVersion)"
} #begin
process {
$Path = Convert-Path $Path
Write-Verbose "[$((Get-Date).TimeOfDay) PROCESS] Processing $Path"
#test if the alternate stream exists
if (Test-Path -LiteralPath "$($Path):$($Stream)") {
try {
Get-Content -Path $Path -Stream $Stream -ErrorAction Stop | ConvertFrom-Json
}
catch {
$_
}
}
} #process
end {
Write-Verbose "[$((Get-Date).TimeOfDay) END ] Ending $($MyInvocation.MyCommand)"
} #end
}