alignfirst 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -128,9 +128,10 @@ function renderCoreGuide(ctx, placeholders) {
128
128
  }
129
129
  function buildGuidePlaceholders(ctx, protocol) {
130
130
  const pattern = ctx.projectConfig?.config.ticketIdPattern;
131
- const detection = pattern === undefined ? undefined : detectTicketFromBranch(ctx.cwd, pattern);
131
+ const template = ctx.projectConfig?.config.git?.branchNameTemplate;
132
+ const detection = detectTicketFromBranch(ctx.cwd, pattern, template);
132
133
  return {
133
- ticketCommand: detection?.kind === "detected" ? "{{CMD}} ticket" : "{{CMD}} ticket <id>",
134
+ ticketCommand: detection.kind === "detected" ? "{{CMD}} ticket" : "{{CMD}} ticket <id>",
134
135
  ticketDetection: renderTicketDetection(pattern, detection),
135
136
  plansState: renderPlansState(ctx),
136
137
  commitRule: renderCommitRule(ctx),
@@ -138,11 +139,11 @@ function buildGuidePlaceholders(ctx, protocol) {
138
139
  };
139
140
  }
140
141
  function renderTicketDetection(pattern, detection) {
142
+ if (detection.kind === "detected")
143
+ return `Current ticket: \`${detection.id}\` (from branch \`${detection.branch}\`). The id argument of \`{{CMD}} ticket\` is optional and defaults to it; pass an id only when the user names another ticket.`;
141
144
  if (pattern === undefined)
142
145
  return "Ask the user for the ticket ID when it is not given.";
143
- if (detection?.kind === "detected")
144
- return `Current ticket: \`${detection.id}\` (from branch \`${detection.branch}\`). The id argument of \`{{CMD}} ticket\` is optional and defaults to it; pass an id only when the user names another ticket.`;
145
- if (detection?.kind === "noMatch")
146
+ if (detection.kind === "noMatch")
146
147
  return `No ticket id on branch \`${detection.branch}\`. Ask the user for the id.`;
147
148
  return "No ticket id: detached HEAD. Ask the user for the id.";
148
149
  }
@@ -5,7 +5,7 @@ import { formatLocalTimestamp, formatSize } from "../format.js";
5
5
  import { parseCommandArgs } from "../parse-args.js";
6
6
  import { renderCatchup } from "../plans/catchup.js";
7
7
  import { assertPlansGate } from "../plans/layout.js";
8
- import { deduceTicketFromBranch, deduceTicketFromExisting, nextFilePosition, peekSideTicket, reserveSideTicket, resolveTicketDir, validateTicketId, } from "../plans/ticket.js";
8
+ import { detectTicketFromBranch, deduceTicketFromExisting, nextFilePosition, peekSideTicket, reserveSideTicket, resolveTicketDir, validateTicketId, } from "../plans/ticket.js";
9
9
  const USAGE = `Usage:
10
10
  {{FORM}} ticket [<id>] [--next [<filename>]] [--new-cycle] [--json] [--dry-run]
11
11
  {{FORM}} ticket --side [--next [<filename>]] [--new-cycle] [--json] [--dry-run]
@@ -70,10 +70,11 @@ function parseTicketArgs(ctx, args, usage) {
70
70
  if (values.catchup &&
71
71
  (values.side || values.next !== undefined || values.json || values["dry-run"]))
72
72
  throw new CliError(`--catchup cannot be combined with --side, --next, --json, or --dry-run.\n\n${usage}`);
73
+ const next = values.next === undefined ? undefined : resolveNextRequest(normalized.requests, usage);
73
74
  const resolution = resolveTicketId(ctx, positionals[0], values);
74
75
  return {
75
76
  ...resolution,
76
- next: values.next === undefined ? undefined : resolveNextRequest(normalized.requests, usage),
77
+ next,
77
78
  newCycle: values["new-cycle"],
78
79
  json: values.json,
79
80
  dryRun: values["dry-run"],
@@ -125,16 +126,23 @@ function validateNextFilename(filename) {
125
126
  function resolveTicketId(ctx, positional, flags) {
126
127
  const pattern = ctx.projectConfig?.config.ticketIdPattern;
127
128
  if (positional !== undefined) {
128
- validateTicketId(positional, pattern);
129
+ validateTicketId(positional);
129
130
  return { id: positional };
130
131
  }
131
132
  if (flags.side)
132
133
  return { id: flags["dry-run"] ? peekSideTicket(ctx.cwd) : reserveSideTicket(ctx.cwd) };
134
+ const template = ctx.projectConfig?.config.git?.branchNameTemplate;
135
+ const detection = detectTicketFromBranch(ctx.cwd, pattern, template);
136
+ if (detection.kind === "detected") {
137
+ validateTicketId(detection.id);
138
+ return detection;
139
+ }
133
140
  if (pattern === undefined)
134
141
  return deduceTicketFromExisting(ctx.cwd, { sideAllowed: !flags.catchup });
135
- const deduced = deduceTicketFromBranch(ctx.cwd, pattern);
136
- validateTicketId(deduced.id, pattern);
137
- return deduced;
142
+ if (detection.kind === "noBranch")
143
+ throw new CliError("Cannot deduce a ticket id from a detached HEAD.");
144
+ const templateDetail = template?.includes("{TICKET_ID}") ? ` and template "${template}"` : "";
145
+ throw new CliError(`Cannot deduce a ticket id from branch "${detection.branch}" with pattern "${pattern}"${templateDetail}.`);
138
146
  }
139
147
  function resolveTicket(ctx, options) {
140
148
  if (options.side && !options.dryRun)
@@ -18,8 +18,8 @@ export function renderConventions(ctx) {
18
18
  function renderTicketIds(ctx) {
19
19
  const pattern = ctx.projectConfig?.config.ticketIdPattern;
20
20
  if (pattern === undefined)
21
- return "Ticket IDs: no configured format; ask the user for the ID. Without an external ticket, use the next `side-N`.";
22
- return `Ticket IDs: \`${pattern}\`; infer a matching ID from the branch. Without an external ticket, use the next \`side-N\`.`;
21
+ return "Ticket IDs: no configured format; ask the user for the ID. Without a ticket, or when the user asks for a side ticket, use the next `side-N`.";
22
+ return `Ticket IDs: \`${pattern}\`; infer a matching ID from the branch. Without a ticket, or when the user asks for a side ticket, use the next \`side-N\`.`;
23
23
  }
24
24
  function renderBranchNames(ctx) {
25
25
  const template = ctx.projectConfig?.config.git?.branchNameTemplate;
@@ -50,10 +50,10 @@ function renderPlans(ctx) {
50
50
  try {
51
51
  const mode = resolvePlansMode(ctx.cwd, ctx.form);
52
52
  const folder = ctx.projectConfig?.config.plans?.folder;
53
- const sharedFolder = folder === undefined ? "" : ` (shared folder \`${folder}\`)`;
53
+ const sharedFolder = folder === undefined ? "" : ` (shared folder \`${folder}\`, a separate git repository)`;
54
54
  const base = mode.kind === "shared"
55
- ? `Plans: use \`.plans\`${sharedFolder}; keep it out of product commits, and run \`${ctx.form} sync\` after changes.`
56
- : "Plans: use `.plans`; keep it out of product commits.";
55
+ ? `Plans: use \`.plans\`${sharedFolder}; run \`${ctx.form} sync\` after changes.`
56
+ : "Plans: use `.plans`.";
57
57
  const archival = ctx.projectConfig?.config.plans?.autoArchive === true
58
58
  ? " Automatic archival is enabled."
59
59
  : "";
@@ -37,11 +37,10 @@ export interface NextFilePosition {
37
37
  export declare function nextFilePosition(entries: readonly string[], newCycle: boolean): NextFilePosition;
38
38
  export declare function listEntries(dir: string): TicketEntry[];
39
39
  export declare function isPathSafeTicketId(id: string): boolean;
40
- export declare function validateTicketId(id: string, pattern?: string): void;
41
- export declare function detectTicketFromBranch(cwd: string, pattern: string): TicketDetection;
40
+ export declare function validateTicketId(id: string): void;
41
+ export declare function detectTicketFromBranch(cwd: string, pattern?: string, template?: string): TicketDetection;
42
42
  export interface DeduceFromExistingOptions {
43
43
  sideAllowed: boolean;
44
44
  }
45
45
  /** Without a ticket id pattern, the branch can still name an existing ticket directory. */
46
46
  export declare function deduceTicketFromExisting(cwd: string, { sideAllowed }: DeduceFromExistingOptions): DeducedTicket;
47
- export declare function deduceTicketFromBranch(cwd: string, pattern: string): DeducedTicket;
@@ -7,9 +7,10 @@ import { gitOutputOrUndefined } from "../git.js";
7
7
  import { archivesDir, isTicketName, plansDir } from "./layout.js";
8
8
  const FILE_PREFIX = /^([A-Z])(\d+)-/;
9
9
  const SIDE_TICKET = /^side-(\d+)$/;
10
+ const BRANCH_SEPARATORS = "[/_.-]";
11
+ const SIDE_TICKET_BRANCH = new RegExp(`^side-\\d+(?=$|${BRANCH_SEPARATORS})`);
10
12
  const PATH_SAFE_TICKET = /^[A-Za-z0-9._-]+$/;
11
13
  const ENTRY_ORDER = new Intl.Collator("en", { numeric: true });
12
- const BRANCH_SEPARATORS = "[/_.-]";
13
14
  const LISTED_TICKETS = 10;
14
15
  export function resolveTicketDir(cwd, id, { dryRun }) {
15
16
  const dir = join(plansDir(cwd), id);
@@ -104,22 +105,49 @@ function statOrLink(path) {
104
105
  export function isPathSafeTicketId(id) {
105
106
  return id !== "." && !id.includes("..") && PATH_SAFE_TICKET.test(id);
106
107
  }
107
- export function validateTicketId(id, pattern) {
108
+ export function validateTicketId(id) {
108
109
  if (!isPathSafeTicketId(id) || !isTicketName(id))
109
110
  throw new CliError(`Invalid ticket id: ${id}`);
110
- if (pattern !== undefined && !new RegExp(pattern).test(id) && !SIDE_TICKET.test(id))
111
- throw new CliError(`Ticket id "${id}" does not match ticketIdPattern "${pattern}".`);
112
111
  }
113
- export function detectTicketFromBranch(cwd, pattern) {
112
+ export function detectTicketFromBranch(cwd, pattern, template) {
114
113
  const branch = currentBranch(cwd);
115
114
  if (branch === undefined)
116
115
  return { kind: "noBranch" };
117
- const unanchored = pattern.replace(/^\^/, "").replace(/\$$/, "");
118
- const match = new RegExp(unanchored).exec(branch);
116
+ const sideTicket = SIDE_TICKET_BRANCH.exec(branch);
117
+ if (sideTicket)
118
+ return { kind: "detected", id: sideTicket[0], branch };
119
+ if (pattern === undefined)
120
+ return { kind: "noMatch", branch };
121
+ if (template?.includes("{TICKET_ID}")) {
122
+ const match = branchPatternFromTemplate(template, unanchorPattern(pattern)).exec(branch);
123
+ const id = match?.[1];
124
+ if (id === undefined || !isPathSafeTicketId(id) || !isTicketName(id))
125
+ return { kind: "noMatch", branch };
126
+ return { kind: "detected", id, branch };
127
+ }
128
+ const match = new RegExp(unanchorPattern(pattern)).exec(branch);
119
129
  if (!match)
120
130
  return { kind: "noMatch", branch };
121
131
  return { kind: "detected", id: match[0], branch };
122
132
  }
133
+ function unanchorPattern(pattern) {
134
+ return pattern.replace(/^\^/, "").replace(/\$$/, "");
135
+ }
136
+ function branchPatternFromTemplate(template, ticketPattern) {
137
+ const placeholder = /\{[^{}]+\}/g;
138
+ let source = "";
139
+ let cursor = 0;
140
+ for (const match of template.matchAll(placeholder)) {
141
+ source += escapeRegexLiteral(template.slice(cursor, match.index));
142
+ source += match[0] === "{TICKET_ID}" ? `((?:${ticketPattern})|side-\\d+)` : ".+";
143
+ cursor = match.index + match[0].length;
144
+ }
145
+ source += escapeRegexLiteral(template.slice(cursor));
146
+ return new RegExp(`^${source}$`);
147
+ }
148
+ function escapeRegexLiteral(value) {
149
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
150
+ }
123
151
  function currentBranch(cwd) {
124
152
  const branch = gitOutputOrUndefined(cwd, "branch", "--show-current");
125
153
  return branch === undefined || branch === "" ? undefined : branch;
@@ -157,7 +185,7 @@ function newestModification(dir) {
157
185
  return new Date(Math.max(...entries.map((entry) => entry.modifiedAt.getTime())));
158
186
  }
159
187
  function branchNamesTicket(branch, id) {
160
- const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
188
+ const escaped = escapeRegexLiteral(id);
161
189
  return new RegExp(`(^|${BRANCH_SEPARATORS})${escaped}($|${BRANCH_SEPARATORS})`).test(branch);
162
190
  }
163
191
  function renderMissingTicketId(tickets, sideAllowed) {
@@ -175,11 +203,3 @@ function renderMissingTicketId(tickets, sideAllowed) {
175
203
  lines.push(` … ${rest} more`);
176
204
  return `No ticket id given. Existing tickets:\n${lines.join("\n")}\n${hint}`;
177
205
  }
178
- export function deduceTicketFromBranch(cwd, pattern) {
179
- const result = detectTicketFromBranch(cwd, pattern);
180
- if (result.kind === "noBranch")
181
- throw new CliError("Cannot deduce a ticket id from a detached HEAD.");
182
- if (result.kind === "noMatch")
183
- throw new CliError(`Cannot deduce a ticket id from branch "${result.branch}" with pattern "${pattern}".`);
184
- return result;
185
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alignfirst",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "license": "CC0-1.0",
5
5
  "author": "Thomas MUR",
6
6
  "description": "The AlignFirst CLI: protocols, plans and docs in one command.",
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Ticket Directory
4
4
 
5
- TICKET_DIR holds a ticket's work files and includes a trailing slash. TICKET_ID is the external ticket ID or `side-N` for work without an external ticket.
5
+ TICKET_DIR holds a ticket's work files and includes a trailing slash. TICKET_ID is the external ticket ID, or `side-N` for a side ticket: work without a ticket.
6
6
 
7
7
  {{TICKET_DETECTION}}
8
8
 
@@ -10,7 +10,7 @@ TICKET_DIR holds a ticket's work files and includes a trailing slash. TICKET_ID
10
10
 
11
11
  {{PLANS_STATE}}
12
12
 
13
- When the user says there is no external ticket, run `{{CMD}} ticket --side` and use the returned TICKET_ID in subsequent ticket commands. Reuse an existing `side-N` directory when the user refers to earlier work. Omit the ticket ID from commit messages.
13
+ When the user says there is no ticket or asks for a side ticket, run `{{CMD}} ticket --side` and use the returned TICKET_ID in subsequent ticket commands. Reuse an existing `side-N` directory when the user refers to earlier work. Omit the ticket ID from commit messages.
14
14
 
15
15
  ## Work Files
16
16
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Prerequisites
4
4
 
5
- Run `{{TICKET_CMD}}` once to identify TICKET_DIR and load the ticket directory context (`{{CMD}} ticket --side` when there is no external ticket).
5
+ Run `{{TICKET_CMD}}` once to identify TICKET_DIR and load the ticket directory context (`{{CMD}} ticket --side` for a side ticket, when there is no ticket).
6
6
 
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Prerequisites
4
4
 
5
- Run `{{TICKET_CMD}}` once to identify TICKET_DIR and load the ticket directory context (`{{CMD}} ticket --side` when there is no external ticket).
5
+ Run `{{TICKET_CMD}}` once to identify TICKET_DIR and load the ticket directory context (`{{CMD}} ticket --side` for a side ticket, when there is no ticket).
6
6
 
7
7
  ## Steps
8
8
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Prerequisites
4
4
 
5
- Run `{{TICKET_CMD}}` once to identify TICKET_DIR and load the ticket directory context (`{{CMD}} ticket --side` when there is no external ticket).
5
+ Run `{{TICKET_CMD}}` once to identify TICKET_DIR and load the ticket directory context (`{{CMD}} ticket --side` for a side ticket, when there is no ticket).
6
6
 
7
7
  ---
8
8
 
@@ -6,7 +6,7 @@
6
6
 
7
7
  You need:
8
8
 
9
- - the TICKET_DIR and ticket directory context — run `{{TICKET_CMD}}` once (`{{CMD}} ticket --side` when there is no external ticket)
9
+ - the TICKET_DIR and ticket directory context — run `{{TICKET_CMD}}` once (`{{CMD}} ticket --side` for a side ticket, when there is no ticket)
10
10
  - a **spec file** in the TICKET_DIR
11
11
 
12
12
  Identify TICKET_DIR and the spec file before starting the protocol. If either is missing, STOP AND ASK THE USER.
@@ -4,7 +4,7 @@
4
4
 
5
5
  You need:
6
6
 
7
- - the TICKET_DIR and ticket directory context — run `{{TICKET_CMD}}` once (`{{CMD}} ticket --side` when there is no external ticket)
7
+ - the TICKET_DIR and ticket directory context — run `{{TICKET_CMD}}` once (`{{CMD}} ticket --side` for a side ticket, when there is no ticket)
8
8
  - {{BASE_BRANCH_RULE}}
9
9
 
10
10
  Identify TICKET_DIR and the base branch before starting the protocol.
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Prerequisites
4
4
 
5
- Run `{{TICKET_CMD}}` once to identify TICKET_DIR and load the ticket directory context (`{{CMD}} ticket --side` when there is no external ticket).
5
+ Run `{{TICKET_CMD}}` once to identify TICKET_DIR and load the ticket directory context (`{{CMD}} ticket --side` for a side ticket, when there is no ticket).
6
6
 
7
7
  ## Phases
8
8