allow explicit pinning of snapshots and commits
This commit is contained in:
parent
ea1a88442a
commit
3f0a40cea7
5 changed files with 777 additions and 138 deletions
|
|
@ -2,6 +2,7 @@ const std = @import("std");
|
||||||
const zfin = @import("../root.zig");
|
const zfin = @import("../root.zig");
|
||||||
const srf = @import("srf");
|
const srf = @import("srf");
|
||||||
const history = @import("../history.zig");
|
const history = @import("../history.zig");
|
||||||
|
const git = @import("../git.zig");
|
||||||
pub const fmt = @import("../format.zig");
|
pub const fmt = @import("../format.zig");
|
||||||
|
|
||||||
// ── Default CLI colors (match TUI default Monokai theme) ─────
|
// ── Default CLI colors (match TUI default Monokai theme) ─────
|
||||||
|
|
@ -574,7 +575,184 @@ pub fn parseRequiredDateOrStderr(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Snapshot resolution (CLI) ────────────────────────────────
|
// ── Commit-spec parsing (shared by contributions / compare) ──
|
||||||
|
|
||||||
|
/// Re-export of `git.CommitSpec` so call sites already using `cli.*`
|
||||||
|
/// don't need a second import.
|
||||||
|
pub const CommitSpec = git.CommitSpec;
|
||||||
|
|
||||||
|
pub const CommitSpecError = error{
|
||||||
|
Empty,
|
||||||
|
InvalidFormat,
|
||||||
|
/// Catch-all for a token that doesn't match any known commit-spec
|
||||||
|
/// shape. Different from `InvalidFormat` in that the string
|
||||||
|
/// could be a SHA or ref — git will decide at invocation time.
|
||||||
|
/// We err on this only when the token has obviously wrong shape.
|
||||||
|
UnknownForm,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Parse a user-facing commit spec into a `CommitSpec`.
|
||||||
|
///
|
||||||
|
/// Accepts (in priority order):
|
||||||
|
/// - case-insensitive `working` / `WORKING` / `wc` / `WC` /
|
||||||
|
/// `working-copy` → `.working_copy`
|
||||||
|
/// - `YYYY-MM-DD` → `.date_at_or_before`
|
||||||
|
/// - Relative date form (`1W`, `1M`, `1Q`, `1Y` — same grammar as
|
||||||
|
/// `--as-of`), resolved against `today` → `.date_at_or_before`
|
||||||
|
/// - Strings starting with `HEAD` (`HEAD`, `HEAD~N`) → `.git_ref`
|
||||||
|
/// - Pure hex ≥ 7 chars → `.git_ref` (SHA, full or abbreviated)
|
||||||
|
///
|
||||||
|
/// Anything else is rejected as `UnknownForm`. Trimming applied.
|
||||||
|
///
|
||||||
|
/// `today` is injected for test determinism, matching
|
||||||
|
/// `parseAsOfDate`'s contract.
|
||||||
|
pub fn parseCommitSpec(input: []const u8, today: zfin.Date) CommitSpecError!CommitSpec {
|
||||||
|
const s = std.mem.trim(u8, input, " \t\r\n");
|
||||||
|
if (s.len == 0) return error.Empty;
|
||||||
|
|
||||||
|
// Working-copy sentinel (case-insensitive).
|
||||||
|
if (std.ascii.eqlIgnoreCase(s, "working") or
|
||||||
|
std.ascii.eqlIgnoreCase(s, "wc") or
|
||||||
|
std.ascii.eqlIgnoreCase(s, "working-copy"))
|
||||||
|
{
|
||||||
|
return .working_copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
// YYYY-MM-DD — 10 chars, two dashes at fixed positions.
|
||||||
|
if (s.len == 10 and s[4] == '-' and s[7] == '-') {
|
||||||
|
const d = zfin.Date.parse(s) catch return error.InvalidFormat;
|
||||||
|
return .{ .date_at_or_before = d };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relative date form (1W, 1M, 1Q, 1Y). Disambiguated from short
|
||||||
|
// SHAs (both can lead with digits) by the trailing unit letter —
|
||||||
|
// W/M/Q/Y case-insensitive. Without it, a token like "1234567"
|
||||||
|
// could be either a 7-char abbreviated SHA or garbage; we treat
|
||||||
|
// it as SHA and let git decide.
|
||||||
|
if (s.len >= 2 and std.ascii.isDigit(s[0])) {
|
||||||
|
const last = std.ascii.toLower(s[s.len - 1]);
|
||||||
|
if (last == 'w' or last == 'm' or last == 'q' or last == 'y') {
|
||||||
|
const as_of = parseAsOfDate(s, today) catch return error.InvalidFormat;
|
||||||
|
if (as_of) |d| return .{ .date_at_or_before = d };
|
||||||
|
return error.InvalidFormat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HEAD / HEAD~N refs.
|
||||||
|
if (std.mem.startsWith(u8, s, "HEAD")) {
|
||||||
|
return .{ .git_ref = s };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure hex with sensible length → treat as SHA; let git validate.
|
||||||
|
if (s.len >= 7) {
|
||||||
|
var all_hex = true;
|
||||||
|
for (s) |c| {
|
||||||
|
if (!std.ascii.isHex(c)) {
|
||||||
|
all_hex = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (all_hex) return .{ .git_ref = s };
|
||||||
|
}
|
||||||
|
|
||||||
|
return error.UnknownForm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human-friendly explanation of a `parseCommitSpec` error.
|
||||||
|
pub fn fmtCommitSpecError(buf: []u8, input: []const u8, err: CommitSpecError) []const u8 {
|
||||||
|
return switch (err) {
|
||||||
|
error.Empty => std.fmt.bufPrint(buf, "Commit spec is empty.", .{}) catch "Commit spec is empty.",
|
||||||
|
error.InvalidFormat => std.fmt.bufPrint(
|
||||||
|
buf,
|
||||||
|
"Commit spec {s} has a date-like shape but couldn't be parsed. Expected YYYY-MM-DD or N[WMQY].",
|
||||||
|
.{input},
|
||||||
|
) catch input,
|
||||||
|
error.UnknownForm => std.fmt.bufPrint(
|
||||||
|
buf,
|
||||||
|
"Commit spec {s} is not recognized. Accepts: 'working', YYYY-MM-DD, relative (1W/1M/1Q/1Y), HEAD / HEAD~N, or a 7+ hex SHA.",
|
||||||
|
.{input},
|
||||||
|
) catch input,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── parseCommitSpec tests ──────────────────────────────────
|
||||||
|
|
||||||
|
test "parseCommitSpec: working-copy sentinels" {
|
||||||
|
const today = zfin.Date.fromYmd(2026, 5, 9);
|
||||||
|
try std.testing.expect((try parseCommitSpec("working", today)) == .working_copy);
|
||||||
|
try std.testing.expect((try parseCommitSpec("WORKING", today)) == .working_copy);
|
||||||
|
try std.testing.expect((try parseCommitSpec("wc", today)) == .working_copy);
|
||||||
|
try std.testing.expect((try parseCommitSpec("WC", today)) == .working_copy);
|
||||||
|
try std.testing.expect((try parseCommitSpec("working-copy", today)) == .working_copy);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseCommitSpec: YYYY-MM-DD → date" {
|
||||||
|
const today = zfin.Date.fromYmd(2026, 5, 9);
|
||||||
|
const spec = try parseCommitSpec("2026-05-04", today);
|
||||||
|
switch (spec) {
|
||||||
|
.date_at_or_before => |d| {
|
||||||
|
try std.testing.expectEqual(@as(i16, 2026), d.year());
|
||||||
|
try std.testing.expectEqual(@as(u8, 5), d.month());
|
||||||
|
try std.testing.expectEqual(@as(u8, 4), d.day());
|
||||||
|
},
|
||||||
|
else => try std.testing.expect(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseCommitSpec: relative 1W → date" {
|
||||||
|
const today = zfin.Date.fromYmd(2026, 5, 9);
|
||||||
|
const spec = try parseCommitSpec("1W", today);
|
||||||
|
switch (spec) {
|
||||||
|
.date_at_or_before => |d| {
|
||||||
|
// 1W ago from 2026-05-09 is 2026-05-02
|
||||||
|
try std.testing.expectEqual(@as(i16, 2026), d.year());
|
||||||
|
try std.testing.expectEqual(@as(u8, 5), d.month());
|
||||||
|
try std.testing.expectEqual(@as(u8, 2), d.day());
|
||||||
|
},
|
||||||
|
else => try std.testing.expect(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseCommitSpec: HEAD and HEAD~N as git_ref" {
|
||||||
|
const today = zfin.Date.fromYmd(2026, 5, 9);
|
||||||
|
switch (try parseCommitSpec("HEAD", today)) {
|
||||||
|
.git_ref => |r| try std.testing.expectEqualStrings("HEAD", r),
|
||||||
|
else => try std.testing.expect(false),
|
||||||
|
}
|
||||||
|
switch (try parseCommitSpec("HEAD~1", today)) {
|
||||||
|
.git_ref => |r| try std.testing.expectEqualStrings("HEAD~1", r),
|
||||||
|
else => try std.testing.expect(false),
|
||||||
|
}
|
||||||
|
switch (try parseCommitSpec("HEAD~12", today)) {
|
||||||
|
.git_ref => |r| try std.testing.expectEqualStrings("HEAD~12", r),
|
||||||
|
else => try std.testing.expect(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseCommitSpec: SHA as git_ref" {
|
||||||
|
const today = zfin.Date.fromYmd(2026, 5, 9);
|
||||||
|
switch (try parseCommitSpec("6942020", today)) {
|
||||||
|
.git_ref => |r| try std.testing.expectEqualStrings("6942020", r),
|
||||||
|
else => try std.testing.expect(false),
|
||||||
|
}
|
||||||
|
switch (try parseCommitSpec("6942020abcdef1234567890", today)) {
|
||||||
|
.git_ref => |r| try std.testing.expectEqualStrings("6942020abcdef1234567890", r),
|
||||||
|
else => try std.testing.expect(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseCommitSpec: rejects unknown shapes" {
|
||||||
|
const today = zfin.Date.fromYmd(2026, 5, 9);
|
||||||
|
try std.testing.expectError(error.Empty, parseCommitSpec("", today));
|
||||||
|
try std.testing.expectError(error.Empty, parseCommitSpec(" ", today));
|
||||||
|
try std.testing.expectError(error.UnknownForm, parseCommitSpec("xyz", today)); // < 7 chars, not HEAD/working
|
||||||
|
try std.testing.expectError(error.UnknownForm, parseCommitSpec("banana", today));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseCommitSpec: trims whitespace" {
|
||||||
|
const today = zfin.Date.fromYmd(2026, 5, 9);
|
||||||
|
try std.testing.expect((try parseCommitSpec(" working ", today)) == .working_copy);
|
||||||
|
}
|
||||||
|
|
||||||
/// Snap a requested snapshot date to the nearest earlier snapshot
|
/// Snap a requested snapshot date to the nearest earlier snapshot
|
||||||
/// that exists in `hist_dir`, printing CLI-friendly stderr messages
|
/// that exists in `hist_dir`, printing CLI-friendly stderr messages
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const zfin = @import("../root.zig");
|
const zfin = @import("../root.zig");
|
||||||
const cli = @import("common.zig");
|
const cli = @import("common.zig");
|
||||||
|
const git = @import("../git.zig");
|
||||||
const fmt = cli.fmt;
|
const fmt = cli.fmt;
|
||||||
const Date = zfin.Date;
|
const Date = zfin.Date;
|
||||||
const history = @import("../history.zig");
|
const history = @import("../history.zig");
|
||||||
|
|
@ -77,41 +78,104 @@ pub fn run(
|
||||||
) !void {
|
) !void {
|
||||||
// ── Parse args ───────────────────────────────────────────
|
// ── Parse args ───────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// `--projections` is an opt-in flag that embeds the
|
// Compare has four orthogonal axes for specifying the before/
|
||||||
// projected-return + safe-withdrawal delta block between the
|
// after endpoints, each with a convenient default and an explicit
|
||||||
// Liquid totals and the per-symbol table. Kept opt-in because
|
// override:
|
||||||
// building the projection side for both dates roughly doubles
|
|
||||||
// command runtime (Monte Carlo SWR search + benchmark trailing
|
|
||||||
// returns for both endpoints). Strip it before positional date
|
|
||||||
// parsing so it can appear anywhere in the arg list. We do NOT
|
|
||||||
// accept `-p` as a shortcut because `-p` is already the global
|
|
||||||
// `--portfolio` flag at the top level — shadowing it here would
|
|
||||||
// confuse users who reach for the short form.
|
|
||||||
//
|
//
|
||||||
// `--no-events` mirrors the `projections` command's flag of the
|
// Snapshot (for liquid totals + per-symbol table):
|
||||||
// same name: it suppresses life events in the underlying
|
// --snapshot-before <DATE> — default from positional arg 1
|
||||||
// projection simulation. Only meaningful alongside
|
// --snapshot-after <DATE> — default: live (or arg 2 if given)
|
||||||
// `--projections` (silently ignored otherwise).
|
//
|
||||||
|
// Commit (for the attribution / contributions diff):
|
||||||
|
// --commit-before <SPEC> — default from positional arg 1
|
||||||
|
// --commit-after <SPEC> — default: HEAD clean / WORKING dirty
|
||||||
|
//
|
||||||
|
// Positional date(s) still drive the happy path (one or two
|
||||||
|
// dates → all four axes). The overrides let the user pull a
|
||||||
|
// single axis independently — e.g. `compare 1W --commit-before HEAD`
|
||||||
|
// when they committed mid-week and the 1W snapshot is still correct
|
||||||
|
// but the 1W commit-at-or-before resolves to the wrong reconciliation.
|
||||||
|
//
|
||||||
|
// Other flags: --projections (embeds the projected-return delta
|
||||||
|
// block), --no-events (with --projections, excludes life events).
|
||||||
|
//
|
||||||
|
// We do NOT accept `-p` as a shortcut because `-p` is already the
|
||||||
|
// global `--portfolio` flag; shadowing it would confuse users
|
||||||
|
// reaching for the short form.
|
||||||
var with_projections = false;
|
var with_projections = false;
|
||||||
var events_enabled = true;
|
var events_enabled = true;
|
||||||
|
var snapshot_before_override: ?Date = null;
|
||||||
|
var snapshot_after_override: ?Date = null;
|
||||||
|
var snapshot_after_live = false; // true if user explicitly set --snapshot-after live
|
||||||
|
var commit_before_override: ?git.CommitSpec = null;
|
||||||
|
var commit_after_override: ?git.CommitSpec = null;
|
||||||
var positional: std.ArrayList([]const u8) = .empty;
|
var positional: std.ArrayList([]const u8) = .empty;
|
||||||
defer positional.deinit(allocator);
|
defer positional.deinit(allocator);
|
||||||
for (cmd_args) |a| {
|
|
||||||
|
const today_for_parse = fmt.todayDate();
|
||||||
|
var arg_i: usize = 0;
|
||||||
|
while (arg_i < cmd_args.len) : (arg_i += 1) {
|
||||||
|
const a = cmd_args[arg_i];
|
||||||
if (std.mem.eql(u8, a, "--projections")) {
|
if (std.mem.eql(u8, a, "--projections")) {
|
||||||
with_projections = true;
|
with_projections = true;
|
||||||
} else if (std.mem.eql(u8, a, "--no-events")) {
|
} else if (std.mem.eql(u8, a, "--no-events")) {
|
||||||
events_enabled = false;
|
events_enabled = false;
|
||||||
|
} else if (std.mem.eql(u8, a, "--snapshot-before") or std.mem.eql(u8, a, "--snapshot-after")) {
|
||||||
|
if (arg_i + 1 >= cmd_args.len) {
|
||||||
|
try cli.stderrPrint("Error: ");
|
||||||
|
try cli.stderrPrint(a);
|
||||||
|
try cli.stderrPrint(" requires a date (YYYY-MM-DD, relative like 1W, or 'live' for --snapshot-after).\n");
|
||||||
|
return error.UnexpectedArg;
|
||||||
|
}
|
||||||
|
const value = cmd_args[arg_i + 1];
|
||||||
|
const is_after = std.mem.eql(u8, a, "--snapshot-after");
|
||||||
|
// --snapshot-after supports 'live' (= current portfolio,
|
||||||
|
// not a snapshot file). --snapshot-before does not.
|
||||||
|
const parsed = cli.parseAsOfDate(value, today_for_parse) catch |err| {
|
||||||
|
var ebuf: [256]u8 = undefined;
|
||||||
|
const msg = cli.fmtAsOfParseError(&ebuf, value, err);
|
||||||
|
try cli.stderrPrint(msg);
|
||||||
|
try cli.stderrPrint("\n");
|
||||||
|
return error.InvalidDate;
|
||||||
|
};
|
||||||
|
if (parsed) |d| {
|
||||||
|
if (is_after) snapshot_after_override = d else snapshot_before_override = d;
|
||||||
|
} else if (is_after) {
|
||||||
|
snapshot_after_live = true;
|
||||||
|
} else {
|
||||||
|
try cli.stderrPrint("Error: --snapshot-before cannot be 'live' — the before side must be an actual snapshot.\n");
|
||||||
|
return error.InvalidDate;
|
||||||
|
}
|
||||||
|
arg_i += 1;
|
||||||
|
} else if (std.mem.eql(u8, a, "--commit-before") or std.mem.eql(u8, a, "--commit-after")) {
|
||||||
|
if (arg_i + 1 >= cmd_args.len) {
|
||||||
|
try cli.stderrPrint("Error: ");
|
||||||
|
try cli.stderrPrint(a);
|
||||||
|
try cli.stderrPrint(" requires a value (working, YYYY-MM-DD, 1W/1M/1Q/1Y, HEAD, HEAD~N, or SHA).\n");
|
||||||
|
return error.UnexpectedArg;
|
||||||
|
}
|
||||||
|
const value = cmd_args[arg_i + 1];
|
||||||
|
const spec = cli.parseCommitSpec(value, today_for_parse) catch |err| {
|
||||||
|
var ebuf: [256]u8 = undefined;
|
||||||
|
const msg = cli.fmtCommitSpecError(&ebuf, value, err);
|
||||||
|
try cli.stderrPrint(msg);
|
||||||
|
try cli.stderrPrint("\n");
|
||||||
|
return error.InvalidDate;
|
||||||
|
};
|
||||||
|
if (std.mem.eql(u8, a, "--commit-before")) {
|
||||||
|
if (spec == .working_copy) {
|
||||||
|
try cli.stderrPrint("Error: --commit-before cannot be `working` — diffing the working copy against itself is meaningless.\n");
|
||||||
|
return error.InvalidDate;
|
||||||
|
}
|
||||||
|
commit_before_override = spec;
|
||||||
|
} else {
|
||||||
|
commit_after_override = spec;
|
||||||
|
}
|
||||||
|
arg_i += 1;
|
||||||
} else if (a.len > 0 and a[0] == '-' and !std.mem.eql(u8, a, "-")) {
|
} else if (a.len > 0 and a[0] == '-' and !std.mem.eql(u8, a, "-")) {
|
||||||
// Any other dash-prefixed token is an unknown compare flag.
|
|
||||||
// Catch it explicitly with a pointed message rather than
|
|
||||||
// letting it fall through to the date parser (which would
|
|
||||||
// emit a generic "invalid date" error). Most likely cause:
|
|
||||||
// user reached for `-p` expecting `--projections` — that
|
|
||||||
// shortcut was intentionally not exposed to avoid
|
|
||||||
// shadowing the global `-p` / `--portfolio` flag.
|
|
||||||
try cli.stderrPrint("Error: unknown flag for 'compare': ");
|
try cli.stderrPrint("Error: unknown flag for 'compare': ");
|
||||||
try cli.stderrPrint(a);
|
try cli.stderrPrint(a);
|
||||||
try cli.stderrPrint("\nKnown flags: --projections, --no-events.\n");
|
try cli.stderrPrint("\nKnown flags: --projections, --no-events, --snapshot-before, --snapshot-after, --commit-before, --commit-after.\n");
|
||||||
if (std.mem.eql(u8, a, "-p")) {
|
if (std.mem.eql(u8, a, "-p")) {
|
||||||
try cli.stderrPrint(" (Tip: the projections flag is spelled `--projections` in full.\n");
|
try cli.stderrPrint(" (Tip: the projections flag is spelled `--projections` in full.\n");
|
||||||
try cli.stderrPrint(" `-p` is reserved for the global --portfolio option and must appear\n");
|
try cli.stderrPrint(" `-p` is reserved for the global --portfolio option and must appear\n");
|
||||||
|
|
@ -124,31 +188,35 @@ pub fn run(
|
||||||
}
|
}
|
||||||
const args = positional.items;
|
const args = positional.items;
|
||||||
|
|
||||||
if (args.len == 0) {
|
// Require at least one source of the "then" date — either a
|
||||||
try cli.stderrPrint("Error: 'compare' requires one or two dates.\n");
|
// positional arg or a --snapshot-before / --commit-before
|
||||||
|
// override that gives us an anchor.
|
||||||
|
const have_then_anchor = args.len >= 1 or snapshot_before_override != null or commit_before_override != null;
|
||||||
|
if (!have_then_anchor) {
|
||||||
|
try cli.stderrPrint("Error: 'compare' requires a before-side anchor (positional date, --snapshot-before, or --commit-before).\n");
|
||||||
try cli.stderrPrint("Usage:\n");
|
try cli.stderrPrint("Usage:\n");
|
||||||
try cli.stderrPrint(" zfin compare [--projections [--no-events]] <DATE> (compare date vs current)\n");
|
try cli.stderrPrint(" zfin compare <DATE> (compare date vs current)\n");
|
||||||
try cli.stderrPrint(" zfin compare [--projections [--no-events]] <DATE1> <DATE2> (compare two dates)\n");
|
try cli.stderrPrint(" zfin compare <DATE1> <DATE2> (compare two dates)\n");
|
||||||
|
try cli.stderrPrint(" zfin compare --snapshot-before <DATE> [--commit-before <SPEC>] (explicit axes)\n");
|
||||||
try cli.stderrPrint("Dates accept YYYY-MM-DD or relative shortcuts: 1W, 1M, 1Q, 1Y.\n");
|
try cli.stderrPrint("Dates accept YYYY-MM-DD or relative shortcuts: 1W, 1M, 1Q, 1Y.\n");
|
||||||
try cli.stderrPrint("Flags:\n");
|
try cli.stderrPrint("See `zfin help` for --commit-before/--commit-after/--snapshot-before/--snapshot-after details.\n");
|
||||||
try cli.stderrPrint(" --projections Include projected return + safe-withdrawal deltas.\n");
|
|
||||||
try cli.stderrPrint(" --no-events (with --projections) Exclude life events from the simulation.\n");
|
|
||||||
return error.MissingDateArg;
|
return error.MissingDateArg;
|
||||||
}
|
}
|
||||||
if (args.len > 2) {
|
if (args.len > 2) {
|
||||||
try cli.stderrPrint("Error: 'compare' takes at most two dates.\n");
|
try cli.stderrPrint("Error: 'compare' takes at most two positional dates.\n");
|
||||||
return error.UnexpectedArg;
|
return error.UnexpectedArg;
|
||||||
}
|
}
|
||||||
|
|
||||||
const today = fmt.todayDate();
|
const today = fmt.todayDate();
|
||||||
const date1 = cli.parseRequiredDateOrStderr(args[0], today, "date1") catch |err| switch (err) {
|
|
||||||
error.InvalidDate => return error.InvalidDate,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Resolve (then_date, now_date, now_is_live). In single-date mode the
|
// Parse positional dates (if any). These feed the snapshot axes
|
||||||
// user-given date is "then" and today is "now" (from the live
|
// by default; explicit overrides win.
|
||||||
// portfolio). In two-date mode both are snapshots and we swap to
|
const date1: ?Date = if (args.len >= 1)
|
||||||
// guarantee older → newer.
|
(cli.parseRequiredDateOrStderr(args[0], today, "date1") catch |err| switch (err) {
|
||||||
|
error.InvalidDate => return error.InvalidDate,
|
||||||
|
})
|
||||||
|
else
|
||||||
|
null;
|
||||||
const date2: ?Date = if (args.len == 2)
|
const date2: ?Date = if (args.len == 2)
|
||||||
(cli.parseRequiredDateOrStderr(args[1], today, "date2") catch |err| switch (err) {
|
(cli.parseRequiredDateOrStderr(args[1], today, "date2") catch |err| switch (err) {
|
||||||
error.InvalidDate => return error.InvalidDate,
|
error.InvalidDate => return error.InvalidDate,
|
||||||
|
|
@ -156,33 +224,64 @@ pub fn run(
|
||||||
else
|
else
|
||||||
null;
|
null;
|
||||||
|
|
||||||
const now_is_live = date2 == null;
|
// Resolve the snapshot "then" / "now" dates.
|
||||||
// SAFETY: both unconditionally assigned in every branch of the
|
// then_date_requested: user-facing before date (for reporting +
|
||||||
// `if (now_is_live)` block below before first read.
|
// attribution fallback). Derived from
|
||||||
var then_date: Date = undefined;
|
// --snapshot-before override first, else
|
||||||
// SAFETY: see above.
|
// positional[0].
|
||||||
var now_date: Date = undefined;
|
// now_date_requested: user-facing after date. From
|
||||||
|
// --snapshot-after override if non-live,
|
||||||
|
// else positional[1] if given, else today.
|
||||||
|
// now_is_live: true when no --snapshot-after-date and
|
||||||
|
// no positional[1] — equivalent to the
|
||||||
|
// old single-date mode.
|
||||||
|
const then_requested_opt: ?Date = snapshot_before_override orelse date1;
|
||||||
|
// If neither a snapshot override nor a positional date was given,
|
||||||
|
// we still need a then-date for the snapshot side. Fall back to
|
||||||
|
// the commit-before's date if it's a date spec.
|
||||||
|
const then_requested: Date = if (then_requested_opt) |d| d else fallback: {
|
||||||
|
if (commit_before_override) |cb| {
|
||||||
|
switch (cb) {
|
||||||
|
.date_at_or_before => |d| break :fallback d,
|
||||||
|
else => {
|
||||||
|
try cli.stderrPrint("Error: --commit-before with a non-date SPEC requires an explicit --snapshot-before date for the liquid comparison.\n");
|
||||||
|
return error.MissingDateArg;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unreachable; // have_then_anchor guarantees at least one source
|
||||||
|
};
|
||||||
|
|
||||||
|
const now_is_live = !snapshot_after_live and snapshot_after_override == null and date2 == null;
|
||||||
|
const now_requested: Date = if (snapshot_after_override) |d| d else if (date2) |d| d else today;
|
||||||
|
|
||||||
|
// Validate snapshot date ordering.
|
||||||
if (now_is_live) {
|
if (now_is_live) {
|
||||||
if (date1.days == today.days) {
|
if (then_requested.days == today.days) {
|
||||||
try cli.stderrPrint("Error: cannot compare today against today's live portfolio.\n");
|
try cli.stderrPrint("Error: cannot compare today against today's live portfolio.\n");
|
||||||
return error.SameDate;
|
return error.SameDate;
|
||||||
}
|
}
|
||||||
// A future date in single-date mode is nonsense — the "then"
|
if (then_requested.days > today.days) {
|
||||||
// snapshot wouldn't exist and we'd compare live-now against
|
|
||||||
// live-now anyway. Reject early.
|
|
||||||
if (date1.days > today.days) {
|
|
||||||
try cli.stderrPrint("Error: cannot compare against a future date.\n");
|
try cli.stderrPrint("Error: cannot compare against a future date.\n");
|
||||||
return error.InvalidDate;
|
return error.InvalidDate;
|
||||||
}
|
}
|
||||||
then_date = date1;
|
} else if (!snapshot_after_live) {
|
||||||
now_date = today;
|
if (then_requested.days == now_requested.days) {
|
||||||
} else {
|
try cli.stderrPrint("Error: before and after dates are the same — nothing to compare.\n");
|
||||||
if (date1.days == date2.?.days) {
|
|
||||||
try cli.stderrPrint("Error: both dates are the same — nothing to compare.\n");
|
|
||||||
return error.SameDate;
|
return error.SameDate;
|
||||||
}
|
}
|
||||||
then_date = if (date1.days < date2.?.days) date1 else date2.?;
|
}
|
||||||
now_date = if (date1.days < date2.?.days) date2.? else date1;
|
|
||||||
|
// Swap if user provided positional dates in reverse order and no
|
||||||
|
// explicit override. Overrides are respected as given (user
|
||||||
|
// asked for it explicitly).
|
||||||
|
var then_date: Date = then_requested;
|
||||||
|
var now_date: Date = now_requested;
|
||||||
|
if (!now_is_live and snapshot_before_override == null and snapshot_after_override == null and
|
||||||
|
date1 != null and date2 != null and date1.?.days > date2.?.days)
|
||||||
|
{
|
||||||
|
then_date = date2.?;
|
||||||
|
now_date = date1.?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Resolve history dir ──────────────────────────────────
|
// ── Resolve history dir ──────────────────────────────────
|
||||||
|
|
@ -192,22 +291,11 @@ pub fn run(
|
||||||
// ── Snap dates to nearest-earlier snapshots ──────────────
|
// ── Snap dates to nearest-earlier snapshots ──────────────
|
||||||
//
|
//
|
||||||
// The requested date may not correspond to an actual snapshot
|
// The requested date may not correspond to an actual snapshot
|
||||||
// file (e.g. `1W` resolves to a weekday but snapshots only exist
|
// file. Snap to the most recent snapshot at-or-before. Record
|
||||||
// for business days, or the cron was down that day). Snap to the
|
// the original requested date so we can pass it to the
|
||||||
// most recent snapshot at-or-before the requested date — same
|
// attribution git-window independently (liquid uses the snap'd
|
||||||
// semantics as `projections --as-of`. When the snap is inexact,
|
// date; attribution uses the requested date unless --commit-*
|
||||||
// overwrite the date used downstream so the rendered header
|
// overrides).
|
||||||
// reflects what was actually loaded.
|
|
||||||
//
|
|
||||||
// IMPORTANT: we keep the *original requested* dates around for
|
|
||||||
// attribution. The attribution is a git-window query that should
|
|
||||||
// match what `zfin contributions --since <SAME_INPUT>` would
|
|
||||||
// produce — if we passed the snapped dates to attribution, the
|
|
||||||
// git window would widen every time the requested date doesn't
|
|
||||||
// land on a snapshot, causing compare's attribution to disagree
|
|
||||||
// with contributions' totals over the same nominal window.
|
|
||||||
// Snapshot loading uses the snapped dates (so we actually find a
|
|
||||||
// file on disk); attribution uses the original dates.
|
|
||||||
var arena_state = std.heap.ArenaAllocator.init(allocator);
|
var arena_state = std.heap.ArenaAllocator.init(allocator);
|
||||||
defer arena_state.deinit();
|
defer arena_state.deinit();
|
||||||
const arena = arena_state.allocator();
|
const arena = arena_state.allocator();
|
||||||
|
|
@ -276,16 +364,29 @@ pub fn run(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build the CommitSpecs for attribution. Explicit --commit-*
|
||||||
|
// overrides win; otherwise fall back to the requested snapshot
|
||||||
|
// date. now_is_live implies `null` for after (= working copy
|
||||||
|
// default handling downstream).
|
||||||
|
const attr_before: git.CommitSpec = commit_before_override orelse
|
||||||
|
.{ .date_at_or_before = then_date_requested };
|
||||||
|
const attr_after_opt: ?git.CommitSpec = if (commit_after_override) |s|
|
||||||
|
s
|
||||||
|
else if (now_is_live)
|
||||||
|
null
|
||||||
|
else
|
||||||
|
.{ .date_at_or_before = now_date_requested };
|
||||||
|
|
||||||
if (now_is_live) {
|
if (now_is_live) {
|
||||||
var now_live = try LiveSide.load(allocator, svc, portfolio_path, color);
|
var now_live = try LiveSide.load(allocator, svc, portfolio_path, color);
|
||||||
defer now_live.deinit(allocator);
|
defer now_live.deinit(allocator);
|
||||||
|
|
||||||
// Attribution spans then_date_requested → HEAD (or working
|
// Attribution uses the resolved CommitSpecs so --commit-*
|
||||||
// copy if dirty). `computeAttribution` with until=null uses
|
// overrides + date fallbacks share one classifier. The
|
||||||
// exactly that semantics. Using the REQUESTED date (not the
|
// spec-form computeAttribution is identical to the date form
|
||||||
// snapped-to-snapshot date) keeps the git window aligned
|
// wrapper — same classifier, same windowing — just without
|
||||||
// with `zfin contributions --since <SAME_INPUT>`.
|
// the Date → CommitSpec.date_at_or_before translation.
|
||||||
const attribution = contributions.computeAttribution(allocator, svc, portfolio_path, then_date_requested, null, color);
|
const attribution = contributions.computeAttributionSpec(allocator, svc, portfolio_path, attr_before, attr_after_opt, color);
|
||||||
|
|
||||||
try renderFromParts(out, color, allocator, .{
|
try renderFromParts(out, color, allocator, .{
|
||||||
.then_date = then_date,
|
.then_date = then_date,
|
||||||
|
|
@ -302,11 +403,7 @@ pub fn run(
|
||||||
var now_side = try compare_core.loadSnapshotSide(allocator, hist_dir, now_date);
|
var now_side = try compare_core.loadSnapshotSide(allocator, hist_dir, now_date);
|
||||||
defer now_side.deinit(allocator);
|
defer now_side.deinit(allocator);
|
||||||
|
|
||||||
// Attribution spans then_date_requested → now_date_requested.
|
const attribution = contributions.computeAttributionSpec(allocator, svc, portfolio_path, attr_before, attr_after_opt, color);
|
||||||
// Using the REQUESTED dates (not the snapped-to-snapshot
|
|
||||||
// dates) keeps the git window aligned with what `zfin
|
|
||||||
// contributions --since <X> --until <Y>` would produce.
|
|
||||||
const attribution = contributions.computeAttribution(allocator, svc, portfolio_path, then_date_requested, now_date_requested, color);
|
|
||||||
|
|
||||||
try renderFromParts(out, color, allocator, .{
|
try renderFromParts(out, color, allocator, .{
|
||||||
.then_date = then_date,
|
.then_date = then_date,
|
||||||
|
|
|
||||||
|
|
@ -131,8 +131,8 @@ pub fn run(
|
||||||
allocator: std.mem.Allocator,
|
allocator: std.mem.Allocator,
|
||||||
svc: *zfin.DataService,
|
svc: *zfin.DataService,
|
||||||
portfolio_path: []const u8,
|
portfolio_path: []const u8,
|
||||||
since: ?Date,
|
before: ?git.CommitSpec,
|
||||||
until: ?Date,
|
after: ?git.CommitSpec,
|
||||||
color: bool,
|
color: bool,
|
||||||
out: *std.Io.Writer,
|
out: *std.Io.Writer,
|
||||||
) !void {
|
) !void {
|
||||||
|
|
@ -144,15 +144,16 @@ pub fn run(
|
||||||
defer arena_state.deinit();
|
defer arena_state.deinit();
|
||||||
const arena = arena_state.allocator();
|
const arena = arena_state.allocator();
|
||||||
|
|
||||||
// Enforce the "--until without --since is ambiguous" rule at the
|
// Enforce the "an `after` with no `before` is ambiguous" rule at
|
||||||
// entry point so `resolveEndpoints`/`git.resolveCommitRange` can
|
// the entry point so `resolveEndpoints`/`git.resolveCommitRangeSpec`
|
||||||
// assume the invariant.
|
// can assume the invariant. The legacy no-flag path passes both
|
||||||
if (since == null and until != null) {
|
// as null and falls through to HEAD~1..HEAD / HEAD..WC.
|
||||||
try cli.stderrPrint("Error: --until requires --since. Use `contributions --since <DATE>` or both.\n");
|
if (before == null and after != null) {
|
||||||
|
try cli.stderrPrint("Error: --until / --commit-after requires --since / --commit-before.\n");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var ctx = prepareReport(allocator, arena, svc, portfolio_path, since, until, color, .verbose) catch return;
|
var ctx = prepareReport(allocator, arena, svc, portfolio_path, before, after, color, .verbose) catch return;
|
||||||
defer ctx.deinit();
|
defer ctx.deinit();
|
||||||
|
|
||||||
try printReport(out, &ctx.report, ctx.endpoints.label, color);
|
try printReport(out, &ctx.report, ctx.endpoints.label, color);
|
||||||
|
|
@ -198,8 +199,8 @@ fn prepareReport(
|
||||||
arena: std.mem.Allocator,
|
arena: std.mem.Allocator,
|
||||||
svc: *zfin.DataService,
|
svc: *zfin.DataService,
|
||||||
portfolio_path: []const u8,
|
portfolio_path: []const u8,
|
||||||
since: ?Date,
|
before_spec: ?git.CommitSpec,
|
||||||
until: ?Date,
|
after_spec: ?git.CommitSpec,
|
||||||
color: bool,
|
color: bool,
|
||||||
verbosity: Verbosity,
|
verbosity: Verbosity,
|
||||||
) PrepareError!ReportContext {
|
) PrepareError!ReportContext {
|
||||||
|
|
@ -224,7 +225,7 @@ fn prepareReport(
|
||||||
}
|
}
|
||||||
const dirty = status == .modified;
|
const dirty = status == .modified;
|
||||||
|
|
||||||
const endpoints = resolveEndpoints(arena, repo, since, until, dirty, verbosity) catch return error.PrepareFailed;
|
const endpoints = resolveEndpoints(arena, repo, before_spec, after_spec, dirty, verbosity) catch return error.PrepareFailed;
|
||||||
|
|
||||||
// Pull both sides: before is always from git; after is either
|
// Pull both sides: before is always from git; after is either
|
||||||
// from git (at some revision) or from the working copy.
|
// from git (at some revision) or from the working copy.
|
||||||
|
|
@ -338,23 +339,27 @@ const Verbosity = enum { verbose, silent };
|
||||||
fn resolveEndpoints(
|
fn resolveEndpoints(
|
||||||
arena: std.mem.Allocator,
|
arena: std.mem.Allocator,
|
||||||
repo: git.RepoInfo,
|
repo: git.RepoInfo,
|
||||||
since: ?Date,
|
before: ?git.CommitSpec,
|
||||||
until: ?Date,
|
after: ?git.CommitSpec,
|
||||||
dirty: bool,
|
dirty: bool,
|
||||||
verbosity: Verbosity,
|
verbosity: Verbosity,
|
||||||
) !Endpoints {
|
) !Endpoints {
|
||||||
const range = git.resolveCommitRange(arena, repo, since, until, dirty) catch |err| {
|
const range = git.resolveCommitRangeSpec(arena, repo, before, after, dirty) catch |err| {
|
||||||
if (verbosity == .verbose) {
|
if (verbosity == .verbose) {
|
||||||
switch (err) {
|
switch (err) {
|
||||||
error.NoCommitAtOrBefore => {
|
error.NoCommitAtOrBefore => {
|
||||||
// Report which flag triggered it. When both are set
|
// Report which spec triggered it. When both are
|
||||||
// we can't easily tell from here; message covers both.
|
// date specs we can't easily tell from here;
|
||||||
var since_buf: [10]u8 = undefined;
|
// covers both by naming the before-side.
|
||||||
const since_str = if (since) |s| s.format(&since_buf) else "(unset)";
|
var before_buf: [10]u8 = undefined;
|
||||||
|
const before_str = specDisplayString(before, &before_buf);
|
||||||
var msg_buf: [256]u8 = undefined;
|
var msg_buf: [256]u8 = undefined;
|
||||||
const msg = std.fmt.bufPrint(&msg_buf, "Error: no commit of {s} at or before {s}.\n", .{ repo.rel_path, since_str }) catch "Error: no commit at or before requested date.\n";
|
const msg = std.fmt.bufPrint(&msg_buf, "Error: no commit of {s} at or before {s}.\n", .{ repo.rel_path, before_str }) catch "Error: no commit at or before requested date.\n";
|
||||||
try cli.stderrPrint(msg);
|
try cli.stderrPrint(msg);
|
||||||
},
|
},
|
||||||
|
error.InvalidArg => {
|
||||||
|
try cli.stderrPrint("Error: --commit-before cannot be `working` — diffing the working copy against itself is meaningless.\n");
|
||||||
|
},
|
||||||
else => {
|
else => {
|
||||||
try cli.stderrPrint("Error resolving commit range: ");
|
try cli.stderrPrint("Error resolving commit range: ");
|
||||||
try cli.stderrPrint(@errorName(err));
|
try cli.stderrPrint(@errorName(err));
|
||||||
|
|
@ -368,22 +373,154 @@ fn resolveEndpoints(
|
||||||
// Label the endpoints based on the resolution mode. Matches the
|
// Label the endpoints based on the resolution mode. Matches the
|
||||||
// legacy phrasing where possible so existing test assertions still
|
// legacy phrasing where possible so existing test assertions still
|
||||||
// pass.
|
// pass.
|
||||||
const label = try buildLabel(arena, range, since, until, dirty);
|
const label = try buildLabelFromSpecs(arena, range, before, after, dirty);
|
||||||
|
|
||||||
// Same-commit warning for the two-date window case. Legit confusion
|
// Same-commit warning for the two-date window case. Legit confusion
|
||||||
// trigger — the user asked for a diff between two dates that both
|
// trigger — the user asked for a diff between two dates that both
|
||||||
// snap to the same commit (e.g., no activity in the window).
|
// snap to the same commit (e.g., no activity in the window).
|
||||||
if (since != null and until != null and verbosity == .verbose) {
|
if (before != null and after != null and verbosity == .verbose) {
|
||||||
if (range.after_rev) |after_rev| {
|
if (range.after_rev) |after_rev| {
|
||||||
if (std.mem.eql(u8, range.before_rev, after_rev)) {
|
if (std.mem.eql(u8, range.before_rev, after_rev)) {
|
||||||
try cli.stderrPrint("Warning: --since and --until resolve to the same commit; no changes to report.\n");
|
try cli.stderrPrint("Warning: before and after resolve to the same commit; no changes to report.\n");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Snap-note warning: when the user provided a date-form spec and
|
||||||
|
// the resolved commit's committer-date is >1 day before the
|
||||||
|
// requested date, emit a muted hint so the user can verify the
|
||||||
|
// selected commit matches their intent. See
|
||||||
|
// `docs/notes/commit-window-edge-case.md` (aka TODO.md) for the
|
||||||
|
// motivating scenario.
|
||||||
|
if (verbosity == .verbose) {
|
||||||
|
try maybeSnapNote(arena, repo, before, range.before_rev, "before");
|
||||||
|
}
|
||||||
|
|
||||||
return .{ .range = range, .label = label };
|
return .{ .range = range, .label = label };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Render a `CommitSpec` for user-facing error messages. Dates and
|
||||||
|
/// working-copy sentinels get formatted; refs are passed through.
|
||||||
|
/// When `spec` is null, returns "(unset)".
|
||||||
|
fn specDisplayString(spec: ?git.CommitSpec, date_buf: *[10]u8) []const u8 {
|
||||||
|
const s = spec orelse return "(unset)";
|
||||||
|
return switch (s) {
|
||||||
|
.git_ref => |r| r,
|
||||||
|
.date_at_or_before => |d| d.format(date_buf),
|
||||||
|
.working_copy => "working",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// If the user's before spec was a date form and the resolved commit
|
||||||
|
/// sits more than 1 day earlier than the requested date, print a
|
||||||
|
/// muted hint. Catches the "I committed after my review date" case
|
||||||
|
/// where `--since 1W` picks up a commit 7+ days before the cutoff.
|
||||||
|
fn maybeSnapNote(
|
||||||
|
arena: std.mem.Allocator,
|
||||||
|
repo: git.RepoInfo,
|
||||||
|
spec: ?git.CommitSpec,
|
||||||
|
resolved_ref: []const u8,
|
||||||
|
label: []const u8,
|
||||||
|
) !void {
|
||||||
|
const s = spec orelse return;
|
||||||
|
const requested_date = switch (s) {
|
||||||
|
.date_at_or_before => |d| d,
|
||||||
|
else => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the committer-date of the resolved commit. `%ct` gives a
|
||||||
|
// Unix timestamp.
|
||||||
|
const ts = git.commitTimestamp(arena, repo.root, resolved_ref) catch return;
|
||||||
|
const commit_date = zfin.Date.fromEpoch(ts);
|
||||||
|
if (!commit_date.lessThan(requested_date)) return;
|
||||||
|
|
||||||
|
const gap_days = requested_date.days - commit_date.days;
|
||||||
|
if (gap_days <= 1) return;
|
||||||
|
|
||||||
|
var req_buf: [10]u8 = undefined;
|
||||||
|
var commit_buf: [10]u8 = undefined;
|
||||||
|
var msg_buf: [320]u8 = undefined;
|
||||||
|
const msg = std.fmt.bufPrint(
|
||||||
|
&msg_buf,
|
||||||
|
"(git {s} uses commit {s} from {s}, {d} day{s} before requested {s} — " ++
|
||||||
|
"use --commit-{s} HEAD or a later date to pin to your latest reconciliation commit)\n",
|
||||||
|
.{
|
||||||
|
label,
|
||||||
|
shortSha(resolved_ref),
|
||||||
|
commit_date.format(&commit_buf),
|
||||||
|
gap_days,
|
||||||
|
if (gap_days == 1) "" else "s",
|
||||||
|
requested_date.format(&req_buf),
|
||||||
|
label,
|
||||||
|
},
|
||||||
|
) catch return;
|
||||||
|
cli.stderrPrint(msg) catch {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abbreviate a commit ref for display. SHAs get shortened to 7
|
||||||
|
/// chars; symbolic refs (HEAD, HEAD~1) stay as-is.
|
||||||
|
fn shortSha(ref: []const u8) []const u8 {
|
||||||
|
if (std.mem.startsWith(u8, ref, "HEAD")) return ref;
|
||||||
|
if (ref.len > 7) return ref[0..7];
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a report-header label from the resolved range + the
|
||||||
|
/// user-provided specs. Date specs render their requested form;
|
||||||
|
/// refs render verbatim; null falls back to the legacy HEAD~/HEAD
|
||||||
|
/// naming.
|
||||||
|
fn buildLabelFromSpecs(
|
||||||
|
arena: std.mem.Allocator,
|
||||||
|
range: git.CommitRange,
|
||||||
|
before: ?git.CommitSpec,
|
||||||
|
after: ?git.CommitSpec,
|
||||||
|
dirty: bool,
|
||||||
|
) ![]const u8 {
|
||||||
|
// Preserve the original date-based label when both specs are
|
||||||
|
// date-form (most common user-facing flow). Fall back to a
|
||||||
|
// spec-agnostic rendering otherwise.
|
||||||
|
const before_date: ?Date = if (before) |b| switch (b) {
|
||||||
|
.date_at_or_before => |d| d,
|
||||||
|
else => null,
|
||||||
|
} else null;
|
||||||
|
const after_date: ?Date = if (after) |a| switch (a) {
|
||||||
|
.date_at_or_before => |d| d,
|
||||||
|
else => null,
|
||||||
|
} else null;
|
||||||
|
|
||||||
|
// If either spec is a non-date form, emit a label describing
|
||||||
|
// the resolved range. Otherwise use the legacy date/label path
|
||||||
|
// for back-compat with snapshot tests.
|
||||||
|
const has_non_date = (before != null and before_date == null) or
|
||||||
|
(after != null and after_date == null);
|
||||||
|
if (has_non_date) {
|
||||||
|
const before_str = try specLabel(arena, before, range.before_rev);
|
||||||
|
const after_str = try specLabelAfter(arena, after, range.after_rev);
|
||||||
|
return std.fmt.allocPrint(arena, "{s} vs {s}", .{ before_str, after_str });
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildLabel(arena, range, before_date, after_date, dirty);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn specLabel(arena: std.mem.Allocator, spec: ?git.CommitSpec, resolved_ref: []const u8) ![]const u8 {
|
||||||
|
const s = spec orelse return arena.dupe(u8, resolved_ref);
|
||||||
|
return switch (s) {
|
||||||
|
.git_ref => |r| arena.dupe(u8, r),
|
||||||
|
.date_at_or_before => |d| blk: {
|
||||||
|
var buf: [10]u8 = undefined;
|
||||||
|
const date_str = d.format(&buf);
|
||||||
|
break :blk std.fmt.allocPrint(arena, "commit at-or-before {s}", .{date_str});
|
||||||
|
},
|
||||||
|
.working_copy => arena.dupe(u8, "working copy"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn specLabelAfter(arena: std.mem.Allocator, spec: ?git.CommitSpec, resolved_ref: ?[]const u8) ![]const u8 {
|
||||||
|
if (spec) |s| return specLabel(arena, s, resolved_ref orelse "working");
|
||||||
|
if (resolved_ref) |r| return arena.dupe(u8, r);
|
||||||
|
return arena.dupe(u8, "working copy");
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the human-readable header label for a resolved range.
|
/// Build the human-readable header label for a resolved range.
|
||||||
fn buildLabel(
|
fn buildLabel(
|
||||||
arena: std.mem.Allocator,
|
arena: std.mem.Allocator,
|
||||||
|
|
@ -485,7 +622,7 @@ pub fn computeAttribution(
|
||||||
) ?AttributionSummary {
|
) ?AttributionSummary {
|
||||||
// `--until` without `--since` is ambiguous; caller is expected to
|
// `--until` without `--since` is ambiguous; caller is expected to
|
||||||
// enforce this at the entry point, but guard here too — the
|
// enforce this at the entry point, but guard here too — the
|
||||||
// prepareReport path sends it through `git.resolveCommitRange`
|
// prepareReport path sends it through `git.resolveCommitRangeSpec`
|
||||||
// which asserts the invariant.
|
// which asserts the invariant.
|
||||||
if (since == null and until != null) return null;
|
if (since == null and until != null) return null;
|
||||||
|
|
||||||
|
|
@ -493,9 +630,43 @@ pub fn computeAttribution(
|
||||||
defer arena_state.deinit();
|
defer arena_state.deinit();
|
||||||
const arena = arena_state.allocator();
|
const arena = arena_state.allocator();
|
||||||
|
|
||||||
var ctx = prepareReport(allocator, arena, svc, portfolio_path, since, until, color, .silent) catch return null;
|
// Wrap legacy date-based inputs as CommitSpec.date_at_or_before
|
||||||
|
// for the shared prepareReport path.
|
||||||
|
const before: ?git.CommitSpec = if (since) |d| .{ .date_at_or_before = d } else null;
|
||||||
|
const after: ?git.CommitSpec = if (until) |d| .{ .date_at_or_before = d } else null;
|
||||||
|
|
||||||
|
var ctx = prepareReport(allocator, arena, svc, portfolio_path, before, after, color, .silent) catch return null;
|
||||||
defer ctx.deinit();
|
defer ctx.deinit();
|
||||||
|
|
||||||
|
return summarizeAttribution(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spec-based variant of `computeAttribution` for callers that
|
||||||
|
/// already have `CommitSpec`s (e.g. `compare --commit-before HEAD`).
|
||||||
|
/// Uses exactly the same classifier as the date form; only the
|
||||||
|
/// argument shape differs.
|
||||||
|
pub fn computeAttributionSpec(
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
svc: *zfin.DataService,
|
||||||
|
portfolio_path: []const u8,
|
||||||
|
before: ?git.CommitSpec,
|
||||||
|
after: ?git.CommitSpec,
|
||||||
|
color: bool,
|
||||||
|
) ?AttributionSummary {
|
||||||
|
if (before == null and after != null) return null;
|
||||||
|
|
||||||
|
var arena_state = std.heap.ArenaAllocator.init(allocator);
|
||||||
|
defer arena_state.deinit();
|
||||||
|
const arena = arena_state.allocator();
|
||||||
|
|
||||||
|
var ctx = prepareReport(allocator, arena, svc, portfolio_path, before, after, color, .silent) catch return null;
|
||||||
|
defer ctx.deinit();
|
||||||
|
|
||||||
|
return summarizeAttribution(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarizeAttribution(ctx: ReportContext) AttributionSummary {
|
||||||
|
|
||||||
// Aggregate. Classification logic matches the full-report sections:
|
// Aggregate. Classification logic matches the full-report sections:
|
||||||
// - New contributions: new_stock + new_cash + new_cd + new_option
|
// - New contributions: new_stock + new_cash + new_cd + new_option
|
||||||
// + cash_contribution (opt-in cash_delta)
|
// + cash_contribution (opt-in cash_delta)
|
||||||
|
|
|
||||||
174
src/git.zig
174
src/git.zig
|
|
@ -40,6 +40,10 @@ pub const Error = error{
|
||||||
/// `resolveCommitRange` was asked for a `since` date with no commit
|
/// `resolveCommitRange` was asked for a `since` date with no commit
|
||||||
/// at or before it — nothing to diff against.
|
/// at or before it — nothing to diff against.
|
||||||
NoCommitAtOrBefore,
|
NoCommitAtOrBefore,
|
||||||
|
/// The caller passed an invalid argument combination — e.g.
|
||||||
|
/// `CommitSpec.working_copy` on the "before" side, which is
|
||||||
|
/// nonsensical.
|
||||||
|
InvalidArg,
|
||||||
OutOfMemory,
|
OutOfMemory,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -51,6 +55,32 @@ pub const RepoInfo = struct {
|
||||||
rel_path: []const u8,
|
rel_path: []const u8,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// A user-facing commit endpoint. Captures the three input shapes
|
||||||
|
/// accepted by `--commit-before` / `--commit-after` (and the
|
||||||
|
/// date-oriented `--since` / `--until` / compare positional args,
|
||||||
|
/// which the command layer parses into this type).
|
||||||
|
///
|
||||||
|
/// - `git_ref` — a string `git show <ref>:<path>` will accept
|
||||||
|
/// directly (SHA, HEAD, HEAD~N). Validation deferred to git.
|
||||||
|
/// - `date_at_or_before` — a calendar date. Resolved at
|
||||||
|
/// `resolveCommitRange` time via `commitAtOrBeforeDate`. Kept as
|
||||||
|
/// a date (not pre-resolved to a SHA) so the snap-note warning
|
||||||
|
/// can compare the resolved commit's timestamp against the
|
||||||
|
/// originally-requested date at report time.
|
||||||
|
/// - `working_copy` — the filesystem state (possibly dirty).
|
||||||
|
/// Valid only as the "after" endpoint — nonsensical as a
|
||||||
|
/// "before" because diffing the working copy against itself
|
||||||
|
/// produces nothing.
|
||||||
|
///
|
||||||
|
/// Defined here (not in `commands/common.zig`) because the concept
|
||||||
|
/// is git-layer. `commands/common.zig` has the user-input parser
|
||||||
|
/// that produces values of this type.
|
||||||
|
pub const CommitSpec = union(enum) {
|
||||||
|
git_ref: []const u8,
|
||||||
|
date_at_or_before: Date,
|
||||||
|
working_copy,
|
||||||
|
};
|
||||||
|
|
||||||
/// Working-tree state of a single tracked path.
|
/// Working-tree state of a single tracked path.
|
||||||
pub const PathStatus = enum {
|
pub const PathStatus = enum {
|
||||||
/// Tracked and has staged and/or unstaged changes.
|
/// Tracked and has staged and/or unstaged changes.
|
||||||
|
|
@ -370,6 +400,38 @@ pub fn commitAtOrBeforeDate(
|
||||||
return try allocator.dupe(u8, trimmed);
|
return try allocator.dupe(u8, trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the committer-date (Unix timestamp, seconds since epoch) of
|
||||||
|
/// a given ref. Accepts anything `git log -1 --format=%ct <ref>`
|
||||||
|
/// accepts: SHAs, HEAD, HEAD~N. Used by the contributions pipeline
|
||||||
|
/// to emit a snap-note when a date-form spec resolves to a commit
|
||||||
|
/// that's far from the user's requested date.
|
||||||
|
pub fn commitTimestamp(
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
root: []const u8,
|
||||||
|
ref: []const u8,
|
||||||
|
) Error!i64 {
|
||||||
|
const result = std.process.Child.run(.{
|
||||||
|
.allocator = allocator,
|
||||||
|
.argv = &.{
|
||||||
|
"git", "-C", root,
|
||||||
|
"log", "-1", "--format=%ct",
|
||||||
|
ref,
|
||||||
|
},
|
||||||
|
.max_output_bytes = 4 * 1024,
|
||||||
|
}) catch return error.GitUnavailable;
|
||||||
|
defer allocator.free(result.stdout);
|
||||||
|
defer allocator.free(result.stderr);
|
||||||
|
|
||||||
|
switch (result.term) {
|
||||||
|
.Exited => |code| if (code != 0) return error.GitLogFailed,
|
||||||
|
else => return error.GitLogFailed,
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = std.mem.trim(u8, result.stdout, " \t\r\n");
|
||||||
|
if (trimmed.len == 0) return error.GitLogFailed;
|
||||||
|
return std.fmt.parseInt(i64, trimmed, 10) catch return error.GitLogFailed;
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve a before/after commit range for diffing `repo.rel_path`.
|
/// Resolve a before/after commit range for diffing `repo.rel_path`.
|
||||||
///
|
///
|
||||||
/// Three modes selected by `since` / `until`:
|
/// Three modes selected by `since` / `until`:
|
||||||
|
|
@ -395,6 +457,86 @@ pub fn commitAtOrBeforeDate(
|
||||||
///
|
///
|
||||||
/// Pure SHA-level output — no labels, no stderr side effects. All
|
/// Pure SHA-level output — no labels, no stderr side effects. All
|
||||||
/// allocations use `arena`.
|
/// allocations use `arena`.
|
||||||
|
/// Resolve a before/after commit range for diffing `repo.rel_path`.
|
||||||
|
///
|
||||||
|
/// Takes `CommitSpec` for each endpoint. When `before` is null,
|
||||||
|
/// applies the legacy default (HEAD~1 clean / HEAD dirty). When
|
||||||
|
/// `after` is null, applies the legacy default for the after side
|
||||||
|
/// based on `dirty`. When explicitly provided, the specs are
|
||||||
|
/// honored as given.
|
||||||
|
///
|
||||||
|
/// Returns `error.NoCommitAtOrBefore` when a `date_at_or_before`
|
||||||
|
/// spec resolves to "no commit at or before this date." Returns
|
||||||
|
/// `error.InvalidArg` when `before` is `.working_copy` (nonsensical).
|
||||||
|
///
|
||||||
|
/// Pure SHA-level output — no labels, no stderr side effects. All
|
||||||
|
/// allocations use `arena`.
|
||||||
|
///
|
||||||
|
/// Three-tier rule for clarity:
|
||||||
|
///
|
||||||
|
/// 1. Both specs explicit → honor as given.
|
||||||
|
/// 2. One null, one explicit → fill the null from legacy defaults,
|
||||||
|
/// keeping the explicit side untouched.
|
||||||
|
/// 3. Both null → full legacy mode: HEAD~1..HEAD (clean) or
|
||||||
|
/// HEAD..working-copy (dirty). Back-compat with pre-flag
|
||||||
|
/// `zfin contributions` invocations.
|
||||||
|
pub fn resolveCommitRangeSpec(
|
||||||
|
arena: std.mem.Allocator,
|
||||||
|
repo: RepoInfo,
|
||||||
|
before: ?CommitSpec,
|
||||||
|
after: ?CommitSpec,
|
||||||
|
dirty: bool,
|
||||||
|
) Error!CommitRange {
|
||||||
|
// Before can't be working_copy — would be diffing against itself.
|
||||||
|
if (before) |b| {
|
||||||
|
if (b == .working_copy) return error.InvalidArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve each endpoint independently.
|
||||||
|
const before_rev: []const u8 = if (before) |b|
|
||||||
|
try resolveSpec(arena, repo, b)
|
||||||
|
else if (dirty)
|
||||||
|
"HEAD"
|
||||||
|
else
|
||||||
|
"HEAD~1";
|
||||||
|
|
||||||
|
const after_rev: ?[]const u8 = if (after) |a|
|
||||||
|
(switch (a) {
|
||||||
|
.working_copy => null,
|
||||||
|
else => try resolveSpec(arena, repo, a),
|
||||||
|
})
|
||||||
|
else if (dirty)
|
||||||
|
null
|
||||||
|
else
|
||||||
|
"HEAD";
|
||||||
|
|
||||||
|
return .{ .before_rev = before_rev, .after_rev = after_rev };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve one non-working `CommitSpec` to a string git can consume.
|
||||||
|
/// Caller handles the `.working_copy` case separately (it's not a
|
||||||
|
/// git ref).
|
||||||
|
fn resolveSpec(arena: std.mem.Allocator, repo: RepoInfo, spec: CommitSpec) Error![]const u8 {
|
||||||
|
return switch (spec) {
|
||||||
|
.git_ref => |r| r,
|
||||||
|
.date_at_or_before => |d| blk: {
|
||||||
|
var buf: [10]u8 = undefined;
|
||||||
|
const date_str = d.format(&buf);
|
||||||
|
const sha = (try commitAtOrBeforeDate(arena, repo.root, repo.rel_path, date_str)) orelse
|
||||||
|
return error.NoCommitAtOrBefore;
|
||||||
|
break :blk sha;
|
||||||
|
},
|
||||||
|
.working_copy => error.InvalidArg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Back-compat wrapper for the original `Date`-based API. Existing
|
||||||
|
/// callers (legacy `zfin contributions --since / --until`) keep
|
||||||
|
/// working unchanged. New callers using explicit commit refs go
|
||||||
|
/// through `resolveCommitRangeSpec`.
|
||||||
|
///
|
||||||
|
/// `until` without `since` is rejected via assertion — the window is
|
||||||
|
/// ambiguous without a starting point.
|
||||||
pub fn resolveCommitRange(
|
pub fn resolveCommitRange(
|
||||||
arena: std.mem.Allocator,
|
arena: std.mem.Allocator,
|
||||||
repo: RepoInfo,
|
repo: RepoInfo,
|
||||||
|
|
@ -403,35 +545,9 @@ pub fn resolveCommitRange(
|
||||||
dirty: bool,
|
dirty: bool,
|
||||||
) Error!CommitRange {
|
) Error!CommitRange {
|
||||||
std.debug.assert(!(since == null and until != null));
|
std.debug.assert(!(since == null and until != null));
|
||||||
|
const before: ?CommitSpec = if (since) |d| .{ .date_at_or_before = d } else null;
|
||||||
// Legacy path: no date window. HEAD~1..HEAD (clean) or
|
const after: ?CommitSpec = if (until) |d| .{ .date_at_or_before = d } else null;
|
||||||
// HEAD..working-copy (dirty). Returns the symbolic revisions
|
return resolveCommitRangeSpec(arena, repo, before, after, dirty);
|
||||||
// verbatim — callers feed them directly to `git show`.
|
|
||||||
if (since == null) {
|
|
||||||
return if (dirty)
|
|
||||||
.{ .before_rev = "HEAD", .after_rev = null }
|
|
||||||
else
|
|
||||||
.{ .before_rev = "HEAD~1", .after_rev = "HEAD" };
|
|
||||||
}
|
|
||||||
|
|
||||||
var since_buf: [10]u8 = undefined;
|
|
||||||
const since_str = since.?.format(&since_buf);
|
|
||||||
const since_sha = (try commitAtOrBeforeDate(arena, repo.root, repo.rel_path, since_str)) orelse
|
|
||||||
return error.NoCommitAtOrBefore;
|
|
||||||
|
|
||||||
if (until) |until_date| {
|
|
||||||
var until_buf: [10]u8 = undefined;
|
|
||||||
const until_str = until_date.format(&until_buf);
|
|
||||||
const until_sha = (try commitAtOrBeforeDate(arena, repo.root, repo.rel_path, until_str)) orelse
|
|
||||||
return error.NoCommitAtOrBefore;
|
|
||||||
return .{ .before_rev = since_sha, .after_rev = until_sha };
|
|
||||||
}
|
|
||||||
|
|
||||||
// --since only: after side is HEAD (or working copy if dirty).
|
|
||||||
return if (dirty)
|
|
||||||
.{ .before_rev = since_sha, .after_rev = null }
|
|
||||||
else
|
|
||||||
.{ .before_rev = since_sha, .after_rev = "HEAD" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────
|
// ── Tests ────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
79
src/main.zig
79
src/main.zig
|
|
@ -78,6 +78,16 @@ const usage =
|
||||||
\\ working copy when dirty). Default: HEAD~1..HEAD.
|
\\ working copy when dirty). Default: HEAD~1..HEAD.
|
||||||
\\ --until <DATE> Upper bound. Pair with --since to diff two
|
\\ --until <DATE> Upper bound. Pair with --since to diff two
|
||||||
\\ commits within a date window.
|
\\ commits within a date window.
|
||||||
|
\\ --commit-before <SPEC> Pin the before commit directly. Takes precedence
|
||||||
|
\\ over --since's date-based resolution. SPEC accepts
|
||||||
|
\\ YYYY-MM-DD, relative (1W/1M/1Q/1Y), HEAD, HEAD~N,
|
||||||
|
\\ or a 7+ hex SHA. Useful when you committed after
|
||||||
|
\\ your review date and --since 1W lands on the
|
||||||
|
\\ wrong commit (try --commit-before HEAD).
|
||||||
|
\\ --commit-after <SPEC> Pin the after commit. Same grammar as
|
||||||
|
\\ --commit-before, plus `working` / `WORKING` for
|
||||||
|
\\ the filesystem working copy. Mutually exclusive
|
||||||
|
\\ with --until.
|
||||||
\\
|
\\
|
||||||
\\Projections command options:
|
\\Projections command options:
|
||||||
\\ --no-events Exclude life events from simulation (baseline view)
|
\\ --no-events Exclude life events from simulation (baseline view)
|
||||||
|
|
@ -99,6 +109,19 @@ const usage =
|
||||||
\\ --no-events (with --projections) Exclude life events from the
|
\\ --no-events (with --projections) Exclude life events from the
|
||||||
\\ underlying projection simulation. Matches the
|
\\ underlying projection simulation. Matches the
|
||||||
\\ `projections --no-events` flag.
|
\\ `projections --no-events` flag.
|
||||||
|
\\ --snapshot-before <DATE> Override the before-snapshot independently of
|
||||||
|
\\ the positional date. DATE accepts YYYY-MM-DD or
|
||||||
|
\\ relative (1W/1M/1Q/1Y). Defaults from positional.
|
||||||
|
\\ --snapshot-after <DATE> Override the after-snapshot. Accepts the same
|
||||||
|
\\ plus `live` for the current portfolio. Defaults
|
||||||
|
\\ from positional arg 2, else live.
|
||||||
|
\\ --commit-before <SPEC> Pin the attribution's before commit. Same SPEC
|
||||||
|
\\ grammar as the contributions flag. When the
|
||||||
|
\\ positional date's commit-at-or-before lands on
|
||||||
|
\\ the wrong commit (committed after review day),
|
||||||
|
\\ pass `HEAD` or an explicit SHA.
|
||||||
|
\\ --commit-after <SPEC> Pin the attribution's after commit. Accepts
|
||||||
|
\\ `working` / `WORKING` for the working copy.
|
||||||
\\
|
\\
|
||||||
\\Environment Variables:
|
\\Environment Variables:
|
||||||
\\ TWELVEDATA_API_KEY Twelve Data API key (primary: prices)
|
\\ TWELVEDATA_API_KEY Twelve Data API key (primary: prices)
|
||||||
|
|
@ -505,6 +528,8 @@ fn runCli() !u8 {
|
||||||
} else if (std.mem.eql(u8, command, "contributions")) {
|
} else if (std.mem.eql(u8, command, "contributions")) {
|
||||||
var since: ?zfin.Date = null;
|
var since: ?zfin.Date = null;
|
||||||
var until: ?zfin.Date = null;
|
var until: ?zfin.Date = null;
|
||||||
|
var before_spec: ?cli.CommitSpec = null;
|
||||||
|
var after_spec: ?cli.CommitSpec = null;
|
||||||
var i: usize = 0;
|
var i: usize = 0;
|
||||||
while (i < cmd_args.len) : (i += 1) {
|
while (i < cmd_args.len) : (i += 1) {
|
||||||
const a = cmd_args[i];
|
const a = cmd_args[i];
|
||||||
|
|
@ -539,18 +564,70 @@ fn runCli() !u8 {
|
||||||
until = resolved;
|
until = resolved;
|
||||||
}
|
}
|
||||||
i += 1; // consume the value
|
i += 1; // consume the value
|
||||||
|
} else if (std.mem.eql(u8, a, "--commit-before") or std.mem.eql(u8, a, "--commit-after")) {
|
||||||
|
if (i + 1 >= cmd_args.len) {
|
||||||
|
try cli.stderrPrint("Error: ");
|
||||||
|
try cli.stderrPrint(a);
|
||||||
|
try cli.stderrPrint(" requires a value (working, YYYY-MM-DD, 1W/1M/1Q/1Y, HEAD, HEAD~N, or SHA).\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const value = cmd_args[i + 1];
|
||||||
|
const today = cli.fmt.todayDate();
|
||||||
|
const spec = cli.parseCommitSpec(value, today) catch |err| {
|
||||||
|
var buf: [256]u8 = undefined;
|
||||||
|
const msg = cli.fmtCommitSpecError(&buf, value, err);
|
||||||
|
try cli.stderrPrint(msg);
|
||||||
|
try cli.stderrPrint("\n");
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
|
if (std.mem.eql(u8, a, "--commit-before")) {
|
||||||
|
if (spec == .working_copy) {
|
||||||
|
try cli.stderrPrint("Error: --commit-before cannot be `working` — diffing the working copy against itself is meaningless.\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
before_spec = spec;
|
||||||
|
} else {
|
||||||
|
after_spec = spec;
|
||||||
|
}
|
||||||
|
i += 1; // consume the value
|
||||||
} else {
|
} else {
|
||||||
try reportUnexpectedArg("contributions", a);
|
try reportUnexpectedArg("contributions", a);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Conflict detection: --since and --commit-before describe the
|
||||||
|
// same axis, same for --until and --commit-after. Taking both
|
||||||
|
// would be ambiguous about which wins.
|
||||||
|
if (since != null and before_spec != null) {
|
||||||
|
try cli.stderrPrint("Error: --since and --commit-before both specify the before side. Pick one.\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (until != null and after_spec != null) {
|
||||||
|
try cli.stderrPrint("Error: --until and --commit-after both specify the after side. Pick one.\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
if (since != null and until != null and since.?.days > until.?.days) {
|
if (since != null and until != null and since.?.days > until.?.days) {
|
||||||
try cli.stderrPrint("Error: --since must be on or before --until.\n");
|
try cli.stderrPrint("Error: --since must be on or before --until.\n");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
// Resolve to CommitSpec for the command. Date flags become
|
||||||
|
// `.date_at_or_before`, commit flags pass through.
|
||||||
|
const before_final: ?cli.CommitSpec = if (before_spec) |s|
|
||||||
|
s
|
||||||
|
else if (since) |d|
|
||||||
|
.{ .date_at_or_before = d }
|
||||||
|
else
|
||||||
|
null;
|
||||||
|
const after_final: ?cli.CommitSpec = if (after_spec) |s|
|
||||||
|
s
|
||||||
|
else if (until) |d|
|
||||||
|
.{ .date_at_or_before = d }
|
||||||
|
else
|
||||||
|
null;
|
||||||
|
|
||||||
const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
|
const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
|
||||||
defer if (pf.resolved) |r| r.deinit(allocator);
|
defer if (pf.resolved) |r| r.deinit(allocator);
|
||||||
try commands.contributions.run(allocator, &svc, pf.path, since, until, color, out);
|
try commands.contributions.run(allocator, &svc, pf.path, before_final, after_final, color, out);
|
||||||
} else if (std.mem.eql(u8, command, "snapshot")) {
|
} else if (std.mem.eql(u8, command, "snapshot")) {
|
||||||
const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
|
const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
|
||||||
defer if (pf.resolved) |r| r.deinit(allocator);
|
defer if (pf.resolved) |r| r.deinit(allocator);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue