Behind the PowerShell Pipeline logo

Behind the PowerShell Pipeline

Archives
Subscribe
January 20, 2026

Streaming File History

In this issue:

  • Getting Content
  • Content Options
  • Adding a Stream
  • Getting Stream Data
  • Updating a Stream
    • What About Add-Content?
  • Helper Functions
    • Get-HistoryStream
    • Update-HistoryStream
  • 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
}
Want to read the full issue?
GitHub
Bluesky
LinkedIn
Mastodon
https://jdhitso...
Powered by Buttondown, the easiest way to start and grow your newsletter.