Skip to content

Commit d5c8478

Browse files
committed
Feat: workspace aware timeline export and storage
Store generated .gittrace files under the currently selected workspace instead of treating exports as standalone output files only. Added Export flow - Resolves the currently active workspace from IWorkspaceContext - Updates export logic to save generated timeline data under the active workspace - Preserves explicit --output/-o fallback behavior - Adds validation to fail gracefully when no workspace is selected Workspace storage - Adds WorkspaceTimelineStorage to handle predictable workspace specific paths - Saves WorkspaceTimelineMetadata sidecar files to identify repository sources - Implements listing of stored timeline files for a workspace CLI integration - Adds WorkTimelinesCommand for listing workspace timelines - Adds WorkTimelinesCommandHandler to present timeline metadata in an organized table - Adds tests for workspace aware export path resolution Result These changes make timeline exports workspace-aware, organizing generated .gittrace files automatically and providing the foundation to easily list and reuse them for workspace specific history views.
1 parent a2606fb commit d5c8478

20 files changed

Lines changed: 1099 additions & 134 deletions

src/Cli/Commands/ExportCommand.cs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@
88
namespace ChangeTrace.Cli.Commands;
99

1010
/// <summary>
11-
/// Represents 'export' CLI command that exports a repository timeline to JSON file.
11+
/// Represents 'export' CLI command that exports a repository timeline to a .gittrace file.
1212
/// </summary>
1313
/// <remarks>
1414
/// <list type="bullet">
1515
/// <item>Implements <see cref="ICliCommand"/> to define the command structure and associate a handler.</item>
1616
/// <item>Registers itself as a singleton via <see cref="AutoRegisterAttribute"/>.</item>
17-
/// <item>Defines arguments and options for specifying the repository, output file, GitHub token, and verbosity.</item>
17+
/// <item>Defines arguments and options for specifying the repository, optional output file, GitHub token, and verbosity.</item>
1818
/// <item>The actual execution logic is handled by <see cref="ExportCommandHandler"/>.</item>
1919
/// </list>
2020
/// </remarks>
@@ -36,7 +36,10 @@ public Command Build()
3636
var cmd = new Command("export", "Export repository timeline");
3737

3838
var repoArg = new Argument<string>("repository") { Description = "Local path or HTTPS URL to Git repository" };
39-
var outputOpt = new Option<string>("--output", "-o") { Description = "Output JSON file path", DefaultValueFactory = _ => "timeline.gittrace" };
39+
var outputOpt = new Option<string?>("--output", "-o")
40+
{
41+
Description = "Explicit output .gittrace path. When omitted, export is saved under the active workspace."
42+
};
4043
var tokenOpt = new Option<string?>("--token", "-r") { Description = "GitHub personal access token" };
4144
var verboseOpt = new Option<bool>("--verbose", "-v") { Description = "Enable verbose logging" };
4245

@@ -47,4 +50,4 @@ public Command Build()
4750

4851
return cmd;
4952
}
50-
}
53+
}

src/Cli/Commands/Profiles/Organizations/OrgCommand.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,9 @@ internal sealed class OrgCommand : ICliCommand
4141
/// </summary>
4242
/// <returns>Configured <see cref="Command"/> for organization subcommands.</returns>
4343
public Command Build()
44-
=> new("org", "Organization management");
45-
}
44+
{
45+
var cmd = new Command("org", "Organization management");
46+
cmd.Aliases.Add("organization");
47+
return cmd;
48+
}
49+
}

src/Cli/Commands/Profiles/Workspaces/WorkCommand.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,9 @@ internal class WorkCommand : ICliCommand
4141
/// </summary>
4242
/// <returns>Configured <see cref="Command"/> for workspace subcommands.</returns>
4343
public Command Build()
44-
=> new("workspace", "Workspace management");
45-
}
44+
{
45+
var cmd = new Command("workspace", "Workspace management");
46+
cmd.Aliases.Add("ws");
47+
return cmd;
48+
}
49+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
using System.CommandLine;
2+
using ChangeTrace.Cli.Handlers.Profiles.Workspaces;
3+
using ChangeTrace.Cli.Interfaces;
4+
using ChangeTrace.Configuration.Discovery;
5+
using Microsoft.Extensions.DependencyInjection;
6+
7+
namespace ChangeTrace.Cli.Commands.Profiles.Workspaces;
8+
9+
[AutoRegister(ServiceLifetime.Singleton)]
10+
internal sealed class WorkCurrentCommand : ICliCommand
11+
{
12+
public Type HandlerType => typeof(WorkCurrentCommandHandler);
13+
14+
public Type Parent => typeof(WorkCommand);
15+
16+
public Command Build()
17+
{
18+
var cmd = new Command("current", "Show active workspace");
19+
cmd.Aliases.Add("status");
20+
cmd.Aliases.Add("ctx");
21+
return cmd;
22+
}
23+
}

src/Cli/Commands/Profiles/Workspaces/WorkListCommand.cs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,13 @@ internal sealed class WorkListCommand : ICliCommand
3535
/// Builds <see cref="Command"/> representing <c>workspace list</c> command.
3636
/// </summary>
3737
/// <returns>Configured <see cref="Command"/> with optional filtering options.</returns>
38-
public Command Build() =>
39-
new("list", "List workspaces")
38+
public Command Build()
39+
{
40+
var cmd = new Command("list", "List workspaces")
4041
{
41-
new Option<string>("--org") { Description = "Organization name" }
42+
new Option<string?>("--org", "-o") { Description = "Organization name. When omitted, all workspaces are listed." }
4243
};
43-
}
44+
cmd.Aliases.Add("ls");
45+
return cmd;
46+
}
47+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
using System.CommandLine;
2+
using ChangeTrace.Cli.Handlers.Profiles.Workspaces;
3+
using ChangeTrace.Cli.Interfaces;
4+
using ChangeTrace.Configuration.Discovery;
5+
using Microsoft.Extensions.DependencyInjection;
6+
7+
namespace ChangeTrace.Cli.Commands.Profiles.Workspaces;
8+
9+
[AutoRegister(ServiceLifetime.Singleton)]
10+
internal sealed class WorkPlayCommand : ICliCommand
11+
{
12+
public Type HandlerType => typeof(WorkPlayCommandHandler);
13+
14+
public Type Parent => typeof(WorkCommand);
15+
16+
public Command Build()
17+
=> new("play", "Play a timeline from the active workspace")
18+
{
19+
new Option<string?>("--repo", "-r")
20+
{
21+
Description = "Repository filter, for example microsoft/msquic or msquic. Defaults to the newest timeline."
22+
},
23+
new Option<bool>("--select", "-s")
24+
{
25+
Description = "Prompt for timeline selection instead of opening the newest match."
26+
},
27+
new Option<bool>("--workspace", "-w")
28+
{
29+
Description = "Prompt for workspace before playing."
30+
}
31+
};
32+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
using System.CommandLine;
2+
using ChangeTrace.Cli.Handlers.Profiles.Workspaces;
3+
using ChangeTrace.Cli.Interfaces;
4+
using ChangeTrace.Configuration.Discovery;
5+
using Microsoft.Extensions.DependencyInjection;
6+
7+
namespace ChangeTrace.Cli.Commands.Profiles.Workspaces;
8+
9+
[AutoRegister(ServiceLifetime.Singleton)]
10+
internal sealed class WorkTimelinesCommand : ICliCommand
11+
{
12+
public Type HandlerType => typeof(WorkTimelinesCommandHandler);
13+
14+
public Type Parent => typeof(WorkCommand);
15+
16+
public Command Build()
17+
{
18+
var cmd = new Command("timelines", "List timeline files stored for the active workspace");
19+
cmd.Aliases.Add("timeline");
20+
cmd.Aliases.Add("tl");
21+
return cmd;
22+
}
23+
}

src/Cli/Commands/Profiles/Workspaces/WorkUseCommand.cs

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ namespace ChangeTrace.Cli.Commands.Profiles.Workspaces;
1414
/// <list type="bullet">
1515
/// <item>Child command of <see cref="WorkCommand"/>.</item>
1616
/// <item>Delegates execution to <see cref="WorkUseCommandHandler"/>.</item>
17-
/// <item>Requires workspace name argument.</item>
18-
/// <item>Optionally specifies the organization using <c>--org</c> option.</item>
17+
/// <item>Accepts optional organization and workspace arguments.</item>
18+
/// <item>Prompts for missing values in interactive terminals.</item>
1919
/// <item>Registered automatically as singleton via <see cref="AutoRegisterAttribute"/>.</item>
2020
/// </list>
2121
/// </remarks>
@@ -36,9 +36,22 @@ internal sealed class WorkUseCommand : ICliCommand
3636
/// Builds the <see cref="Command"/> instance representing the <c>workspace use</c> command.
3737
/// </summary>
3838
/// <returns>A configured <see cref="Command"/> with required arguments and optional organization filter.</returns>
39-
public Command Build() => new("use", "Select workspace to play")
39+
public Command Build()
4040
{
41-
new Argument<string>("org") { Description = "Organization name" },
42-
new Argument<string>("name") { Description = "Workspace name" }
43-
};
44-
}
41+
var cmd = new Command("use", "Select active workspace");
42+
cmd.Aliases.Add("switch");
43+
cmd.Aliases.Add("select");
44+
cmd.Arguments.Add(new Argument<string?>("org")
45+
{
46+
Description = "Organization name",
47+
Arity = ArgumentArity.ZeroOrOne
48+
});
49+
cmd.Arguments.Add(new Argument<string?>("name")
50+
{
51+
Description = "Workspace name",
52+
Arity = ArgumentArity.ZeroOrOne
53+
});
54+
55+
return cmd;
56+
}
57+
}

src/Cli/Commands/ShowTimelineCommand.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
using System.CommandLine;
22
using ChangeTrace.Cli.Handlers;
33
using ChangeTrace.Cli.Interfaces;
4-
using ChangeTrace.Configuration;
54
using ChangeTrace.Configuration.Discovery;
65
using Microsoft.Extensions.DependencyInjection;
76

@@ -27,7 +26,7 @@ internal sealed class ShowTimelineCommand : ICliCommand
2726
public Type HandlerType => typeof(ShowTimelineCommandHandler);
2827

2928
public Type? Parent => null;
30-
29+
3130
/// <summary>
3231
/// Builds the <see cref="Command"/> instance representing the 'show' CLI command.
3332
/// </summary>
@@ -40,4 +39,4 @@ public Command Build()
4039
cmd.Arguments.Add(fileArg);
4140
return cmd;
4241
}
43-
}
42+
}

src/Cli/Handlers/ExportCommandHandler.cs

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,34 +12,46 @@
1212
namespace ChangeTrace.Cli.Handlers;
1313

1414
/// <summary>
15-
/// CLI handler responsible for exporting a Git repository into a ChangeTrace timeline file.
15+
/// Exports Git repository into ChangeTrace timeline file.
1616
/// </summary>
17-
/// <remarks>
18-
/// <list type="bullet">
19-
/// <item>Resolves authentication token from CLI option or stored session.</item>
20-
/// <item>Detects repository provider automatically.</item>
21-
/// <item>Invokes <see cref="IRepositoryExporter"/> to perform export pipeline.</item>
22-
/// <item>Displays progress and result using Spectre.Console UI.</item>
23-
/// </list>
24-
/// </remarks>
2517
[AutoRegister(ServiceLifetime.Transient, typeof(ExportCommandHandler))]
2618
internal sealed class ExportCommandHandler(
2719
IAuthService sessionAuthStore,
20+
IWorkspaceContext workspaceContext,
21+
IWorkspaceTimelineStorage workspaceTimelineStorage,
2822
IRepositoryExporter exporter) : ICliHandler
2923
{
3024
/// <summary>
31-
/// Executes the export command.
25+
/// Runs the repository export command.
3226
/// </summary>
33-
/// <param name="parseResult">Parsed CLI arguments.</param>
34-
/// <param name="ct">Cancellation token.</param>
35-
/// <returns>Task representing asynchronous command execution.</returns>
3627
public async Task HandleAsync(ParseResult parseResult, CancellationToken ct)
3728
{
3829
var repo = parseResult.GetValue<string>("repository")!;
39-
var output = parseResult.GetValue<string>("--output")!;
30+
var explicitOutput = parseResult.GetValue<string?>("--output");
4031
var token = parseResult.GetValue<string?>("--token");
4132
var verbose = parseResult.GetValue<bool>("--verbose");
42-
33+
var exportedAt = DateTimeOffset.UtcNow;
34+
35+
var output = explicitOutput;
36+
var workspace = workspaceContext.Current;
37+
38+
if (string.IsNullOrWhiteSpace(output))
39+
{
40+
if (workspace == null)
41+
{
42+
AnsiConsole.MarkupLine(
43+
"[red]Failed:[/] no active workspace selected. Use [yellow]workspace use <org> <name>[/] or pass [yellow]--output/-o[/].");
44+
return;
45+
}
46+
47+
output = await workspaceTimelineStorage.CreateTimelinePathAsync(
48+
workspace,
49+
repo,
50+
exportedAt,
51+
Ulid.NewUlid().ToString(),
52+
ct);
53+
}
54+
4355
if (string.IsNullOrWhiteSpace(token))
4456
{
4557
var provider = ProviderUrlHelper.DetectProvider(repo);
@@ -71,8 +83,15 @@ public async Task HandleAsync(ParseResult parseResult, CancellationToken ct)
7183
return await exporter.ExportAndSaveAsync(repo, output, options, progress, ct);
7284
});
7385

74-
AnsiConsole.MarkupLine(result.IsSuccess
75-
? $"[green]Exported successfully to {output}[/]"
76-
: $"[red]Failed: {result.Error}[/]");
86+
if (result.IsFailure)
87+
{
88+
AnsiConsole.MarkupLine($"[red]Failed:[/] {Markup.Escape(result.Error ?? "Unknown error")}");
89+
return;
90+
}
91+
92+
if (string.IsNullOrWhiteSpace(explicitOutput) && workspace != null)
93+
await workspaceTimelineStorage.SaveMetadataAsync(output, workspace, repo, exportedAt, ct);
94+
95+
AnsiConsole.MarkupLine($"[green]Exported successfully to[/] {Markup.Escape(output)}");
7796
}
7897
}

0 commit comments

Comments
 (0)