From c4ee256cc2f48adca54171c6afc2ec61718977a3 Mon Sep 17 00:00:00 2001 From: Christopher Wellons Date: Sun, 5 Apr 2026 22:00:33 -0400 Subject: [PATCH] Introduce quilt command (Quilt.cpp) This is a primitive source control system for w64devkit, a C++ clone of a classic, old-school tool still in some use today. --- Dockerfile | 2 + etc/quilt.quiltrc | 84 + src/quilt.cpp | 9534 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 9620 insertions(+) create mode 100644 etc/quilt.quiltrc create mode 100644 src/quilt.cpp diff --git a/Dockerfile b/Dockerfile index 75b90d8..be64299 100644 --- a/Dockerfile +++ b/Dockerfile @@ -812,6 +812,8 @@ RUN printf "id ICON \"$PREFIX/src/w64devkit.ico\"" >w64devkit.rc \ -Oz -fno-asynchronous-unwind-tables -fno-builtin -Wl,--gc-sections \ -s -nostdlib -o $PREFIX/bin/recycle.exe $PREFIX/src/recycle.c \ -lkernel32 -lshell32 -lmemory \ + && $ARCH-g++ -std=c++20 -O2 -fno-exceptions -s -o $PREFIX/bin/quilt.exe \ + $PREFIX/src/quilt.cpp \ && $ARCH-gcc -DEXE=pkg-config.exe -DCMD=pkg-config \ -Oz -fno-asynchronous-unwind-tables -Wl,--gc-sections -s -nostdlib \ -o $PREFIX/bin/$ARCH-pkg-config.exe $PREFIX/src/alias.c -lkernel32 \ diff --git a/etc/quilt.quiltrc b/etc/quilt.quiltrc new file mode 100644 index 0000000..71feead --- /dev/null +++ b/etc/quilt.quiltrc @@ -0,0 +1,84 @@ +# /etc/quilt.quiltrc — system-wide defaults for quilt +# +# This file is sourced by quilt on startup (after ~/.quiltrc). Set +# variables here to configure quilt behavior. Lines beginning with # +# are comments. Values may be unquoted, single-quoted (literal), or +# double-quoted (with \" and \\ escapes). +# +# Search order: ~/.quiltrc, then /etc/quilt.quiltrc. Use --quiltrc to +# override, or --quiltrc - to skip all config files. + +# ── Patch and series locations ────────────────────────────────────── + +# Directory where patch files are stored, relative to the source tree +# root. Quilt searches upward from the working directory for a directory +# containing this subdirectory to find the source root. +#QUILT_PATCHES=patches + +# Name of the series file within the patches directory. The series file +# lists patches in application order, one per line, with optional -pN +# strip-level and -R flags. +#QUILT_SERIES=series + +# Directory for backup files and metadata (.version, applied-patches). +# Analogous to .git for git — this is where quilt tracks patch state. +#QUILT_PC=.pc + +# ── Diff and patch generation ────────────────────────────────────── + +# Suppress Index: header lines in generated patches. These lines are +# informational and not used by patch(1), so omitting them produces +# cleaner diffs that are easier to review. +QUILT_NO_DIFF_INDEX=1 + +# Suppress timestamps in --- and +++ diff headers. Timestamps cause +# unnecessary churn when refreshing patches and are not needed for +# applying them. +QUILT_NO_DIFF_TIMESTAMPS=1 + +# Default diff algorithm for the built-in diff engine, used by both +# the diff and refresh commands. Supported values: myers (default), +# minimal, patience, histogram. The --diff-algorithm flag on the +# command line overrides this. This variable is a quilt.cpp extension +# and is safely ignored by original quilt. +#QUILT_DIFF_ALGORITHM=myers + +# Extra options passed to the diff program when generating patches. +# For example, -p to show C function names in hunk headers when using +# an external diff. Parsed with shell-like splitting. +#QUILT_DIFF_OPTS= + +# Extra options passed to patch(1) (or the built-in patch engine) +# during push and fold operations. Parsed with shell-like splitting. +#QUILT_PATCH_OPTS= + +# ── Output formatting ───────────────────────────────────────────── + +# When set, patch names in output are prefixed with the patches +# directory (e.g. "patches/foo.patch" instead of "foo.patch"). +#QUILT_PATCHES_PREFIX= + +# ── Default command arguments ────────────────────────────────────── +# +# These inject arguments at the beginning of the named command's +# argument list, as if you had typed them on the command line. Values +# are parsed with shell-like splitting (single/double quotes, $VAR +# expansion). Any QUILT__ARGS variable is supported — the +# command name is uppercased (e.g. QUILT_PUSH_ARGS, QUILT_POP_ARGS, +# QUILT_IMPORT_ARGS, QUILT_MAIL_ARGS, etc.). + +# Default arguments for refresh. -p ab uses a/file and b/file path +# labels instead of file.orig/file, matching git diff output. +QUILT_REFRESH_ARGS="-p ab" + +# Default arguments for diff. +#QUILT_DIFF_ARGS= + +# Default arguments for push. +#QUILT_PUSH_ARGS= + +# ── External programs ────────────────────────────────────────────── + +# Editor used by quilt edit and quilt header -e. Falls back to the +# system default if unset. +#EDITOR=vi diff --git a/src/quilt.cpp b/src/quilt.cpp new file mode 100644 index 0000000..7851735 --- /dev/null +++ b/src/quilt.cpp @@ -0,0 +1,9534 @@ +// quilt.cpp — single-file amalgamation (Windows platform) +// $ c++ -std=c++20 -o quilt.exe quilt.cpp -lshell32 +// $ cl /std:c++20 /EHsc quilt.cpp shell32.lib +// This is free and unencumbered software released into the public domain. + +#define QUILT_VERSION "0.69" + +// === src/quilt.hpp === + +// This is free and unencumbered software released into the public domain. +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Checked integer cast: asserts value is representable in the target type. +template +constexpr To checked_cast(From value) { + static_assert(std::is_integral_v && std::is_integral_v); + assert(std::in_range(value)); + return static_cast(value); +} + +// Parse a decimal integer from a string_view. Returns 0 on failure. +inline ptrdiff_t parse_int(std::string_view s) { + ptrdiff_t val = 0; + std::from_chars(s.data(), s.data() + s.size(), val); + return val; +} + +// find/rfind wrappers returning ptrdiff_t (-1 for not-found). +inline ptrdiff_t str_find(std::string_view s, char c, ptrdiff_t pos = 0) { + auto r = s.find(c, checked_cast(pos)); + return r == std::string_view::npos ? ptrdiff_t{-1} : static_cast(r); +} +inline ptrdiff_t str_find(std::string_view s, std::string_view needle, ptrdiff_t pos = 0) { + auto r = s.find(needle, checked_cast(pos)); + return r == std::string_view::npos ? ptrdiff_t{-1} : static_cast(r); +} +inline ptrdiff_t str_rfind(std::string_view s, char c) { + auto r = s.rfind(c); + return r == std::string_view::npos ? ptrdiff_t{-1} : static_cast(r); +} + +// Quilt .pc/ directory state +struct QuiltState { + std::string work_dir; // project root + std::string patches_dir; // typically "patches" + std::string pc_dir; // typically ".pc" + std::string series_file; // "patches/series" + std::string subdir; // cwd relative to work_dir (empty at root) + bool series_file_exists = false; + + std::vector series; // ordered patch names from series file + std::vector applied; // applied patch names from .pc/applied-patches + std::map patch_strip_level; // per-patch strip level from series + std::set patch_reversed; // patches marked -R in series + std::map config; // merged quiltrc + env settings + + // Computed helpers + ptrdiff_t top_index() const; // index of topmost applied in series (-1 if none) + bool is_applied(std::string_view patch) const; + std::optional find_in_series(std::string_view patch) const; + int get_strip_level(std::string_view patch) const; // returns 1 if not set + std::string get_p_format(std::string_view patch) const; // "0" or "1" +}; + +// I/O helpers +void out(std::string_view s); +void out_line(std::string_view s); +void err(std::string_view s); +void err_line(std::string_view s); + +// Path utilities +std::string path_join(std::string_view a, std::string_view b); +std::string path_join(std::string_view a, std::string_view b, std::string_view c); +std::string basename(std::string_view path); +std::string dirname(std::string_view path); +std::string strip_trailing_slash(std::string_view s); + +// String utilities +std::string trim(std::string_view s); +std::vector split_lines(std::string_view s); +std::vector split_on_whitespace(std::string_view s); +std::vector shell_split(std::string_view s); + +// Built-in patch engine +struct PatchOptions { + int strip_level = 1; // -pN + int fuzz = 2; // --fuzz=N (default 2) + bool reverse = false; // -R + bool dry_run = false; // --dry-run + bool force = false; // -f + bool remove_empty = false; // -E + bool quiet = false; // -s + bool merge = false; // --merge + std::string merge_style; // "" or "diff3" + // In-memory filesystem for fuzz testing. When non-null, all file I/O + // in builtin_patch uses this map instead of real syscalls. + // Key present = file exists, value = content. + std::map *fs = nullptr; +}; + +struct PatchResult { + int exit_code; // 0=success, 1=rejects + std::string out; // stdout-equivalent messages + std::string err; // stderr-equivalent messages +}; + +PatchResult builtin_patch(std::string_view patch_text, const PatchOptions &opts); + +// Built-in diff engine +enum class DiffFormat { unified, context }; +enum class DiffAlgorithm { myers, minimal, patience, histogram }; + +std::optional parse_diff_algorithm(std::string_view name); + +struct DiffResult { + int exit_code; // 0 = identical, 1 = different + std::string output; // formatted diff text +}; + +DiffResult builtin_diff(std::string_view old_path, std::string_view new_path, + int context_lines = 3, + std::string_view old_label = {}, + std::string_view new_label = {}, + DiffFormat format = DiffFormat::unified, + DiffAlgorithm algorithm = DiffAlgorithm::myers, + std::map *fs = nullptr); + +// Patch name helpers — shared across command files +inline std::string_view strip_patches_prefix(const QuiltState &q, std::string_view name) { + if (name.starts_with(q.patches_dir) && + std::ssize(name) > std::ssize(q.patches_dir) && + name[checked_cast(std::ssize(q.patches_dir))] == '/') { + return name.substr(checked_cast(std::ssize(q.patches_dir) + 1)); + } + return name; +} + +std::string format_patch(const QuiltState &q, std::string_view name); + +inline std::string patch_path_display(const QuiltState &q, std::string_view name) { + return format_patch(q, name); +} + +// Resolve a user-provided file path relative to the current subdirectory. +inline std::string subdir_path(const QuiltState &q, std::string_view file) { + if (q.subdir.empty()) return std::string(file); + return q.subdir + "/" + std::string(file); +} + +// Core helpers — defined in core.cpp +bool ensure_pc_dir(QuiltState &q); +std::string pc_patch_dir(const QuiltState &q, std::string_view patch); +std::vector files_in_patch(const QuiltState &q, std::string_view patch); +bool backup_file(QuiltState &q, std::string_view patch, std::string_view file); +bool restore_file(QuiltState &q, std::string_view patch, std::string_view file); +std::vector read_series(std::string_view path, + std::map *strip_levels, + std::set *reversed); +bool write_series(std::string_view path, std::span patches, + const std::map &strip_levels, + const std::set &reversed); +std::vector read_applied(std::string_view path); +bool write_applied(std::string_view path, std::span patches); + +// Command function type +using CmdFn = int (*)(QuiltState &q, int argc, char **argv); + +struct Command { + const char *name; + CmdFn fn; + const char *usage; + const char *description; +}; + +// Command implementations — cmd_stack.cpp +int cmd_series(QuiltState &q, int argc, char **argv); +int cmd_applied(QuiltState &q, int argc, char **argv); +int cmd_unapplied(QuiltState &q, int argc, char **argv); +int cmd_top(QuiltState &q, int argc, char **argv); +int cmd_next(QuiltState &q, int argc, char **argv); +int cmd_previous(QuiltState &q, int argc, char **argv); +int cmd_push(QuiltState &q, int argc, char **argv); +int cmd_pop(QuiltState &q, int argc, char **argv); + +// Command implementations — cmd_patch.cpp +int cmd_new(QuiltState &q, int argc, char **argv); +int cmd_add(QuiltState &q, int argc, char **argv); +int cmd_remove(QuiltState &q, int argc, char **argv); +int cmd_edit(QuiltState &q, int argc, char **argv); +int cmd_refresh(QuiltState &q, int argc, char **argv); +int cmd_diff(QuiltState &q, int argc, char **argv); +int cmd_revert(QuiltState &q, int argc, char **argv); + +// Command implementations — cmd_manage.cpp +int cmd_delete(QuiltState &q, int argc, char **argv); +int cmd_rename(QuiltState &q, int argc, char **argv); +int cmd_import(QuiltState &q, int argc, char **argv); +int cmd_header(QuiltState &q, int argc, char **argv); +int cmd_files(QuiltState &q, int argc, char **argv); +int cmd_patches(QuiltState &q, int argc, char **argv); +int cmd_fold(QuiltState &q, int argc, char **argv); +int cmd_fork(QuiltState &q, int argc, char **argv); + +// Command implementations — cmd_mail.cpp +int cmd_mail(QuiltState &q, int argc, char **argv); + +// Command implementations — cmd_patch.cpp (continued) +int cmd_snapshot(QuiltState &q, int argc, char **argv); +int cmd_init(QuiltState &q, int argc, char **argv); + +// Command implementations — cmd_manage.cpp (continued) +int cmd_upgrade(QuiltState &q, int argc, char **argv); + +// Command implementations — cmd_annotate.cpp +int cmd_annotate(QuiltState &q, int argc, char **argv); + +// Command implementations — cmd_graph.cpp +int cmd_graph(QuiltState &q, int argc, char **argv); + +// Command stubs — cmd_stubs.cpp +int cmd_grep(QuiltState &q, int argc, char **argv); +int cmd_setup(QuiltState &q, int argc, char **argv); +int cmd_shell(QuiltState &q, int argc, char **argv); + +// === src/platform.hpp === + +// This is free and unencumbered software released into the public domain. +#include +#include +#include +#include + +// Process execution +struct ProcessResult { + int exit_code; + std::string out; + std::string err; +}; + +ProcessResult run_cmd(const std::vector &argv); +ProcessResult run_cmd_input(const std::vector &argv, + std::string_view stdin_data); +int run_cmd_tty(const std::vector &argv); + +// File system operations +std::string read_file(std::string_view path); +bool write_file(std::string_view path, std::string_view content); +bool append_file(std::string_view path, std::string_view content); +bool copy_file(std::string_view src, std::string_view dst); +bool rename_path(std::string_view old_path, std::string_view new_path); +bool delete_file(std::string_view path); +bool delete_dir_recursive(std::string_view path); +bool make_dir(std::string_view path); +bool make_dirs(std::string_view path); +bool file_exists(std::string_view path); +bool is_directory(std::string_view path); +int64_t file_mtime(std::string_view path); // -1 on failure + +struct DirEntry { + std::string name; + bool is_dir; +}; +std::vector list_dir(std::string_view path); + +// Find all regular files recursively under a directory (relative paths) +std::vector find_files_recursive(std::string_view dir); + +// Create a new unique temporary directory; returns its path, or empty on failure +std::string make_temp_dir(); + +// Environment +std::string get_env(std::string_view name); +void set_env(std::string_view name, std::string_view value); +std::string get_home_dir(); +std::string get_cwd(); +bool set_cwd(std::string_view path); +std::string get_system_quiltrc(); + +// I/O +void fd_write_stdout(std::string_view s); +void fd_write_stderr(std::string_view s); +bool stdout_is_tty(); + +// Read all of stdin +std::string read_stdin(); + +// Time +struct DateTime { + int year; // e.g. 2026 + int month; // 1-12 + int day; // 1-31 + int hour; // 0-23 + int min; // 0-59 + int sec; // 0-59 + int weekday; // 0=Sun, 1=Mon, ..., 6=Sat + int utc_offset; // seconds east of UTC +}; + +int64_t current_time(); // seconds since Unix epoch +DateTime local_time(int64_t timestamp); // broken-down local time + UTC offset + +// Entry point called by platform main +int quilt_main(int argc, char **argv); + +// === src/core.cpp === + +// This is free and unencumbered software released into the public domain. + +ptrdiff_t QuiltState::top_index() const { + if (applied.empty()) return -1; + const std::string &top = applied.back(); + for (ptrdiff_t i = 0; i < std::ssize(series); ++i) { + if (series[checked_cast(i)] == top) return i; + } + return -1; +} + +bool QuiltState::is_applied(std::string_view patch) const { + for (const auto &a : applied) { + if (a == patch) return true; + } + return false; +} + +std::optional QuiltState::find_in_series(std::string_view patch) const { + for (ptrdiff_t i = 0; i < std::ssize(series); ++i) { + if (series[checked_cast(i)] == patch) return i; + } + return std::nullopt; +} + +int QuiltState::get_strip_level(std::string_view patch) const { + auto it = patch_strip_level.find(std::string(patch)); + if (it != patch_strip_level.end()) return it->second; + return 1; +} + +std::string QuiltState::get_p_format(std::string_view patch) const { + if (get_strip_level(patch) == 0) return "0"; + return "1"; +} + +void out(std::string_view s) { + fd_write_stdout(s); +} + +void out_line(std::string_view s) { + fd_write_stdout(s); + fd_write_stdout("\n"); +} + +void err(std::string_view s) { + fd_write_stderr(s); +} + +void err_line(std::string_view s) { + fd_write_stderr(s); + fd_write_stderr("\n"); +} + +static bool is_absolute_path(std::string_view p) { + if (!p.empty() && p[0] == '/') return true; + // Windows: drive letter (e.g. C:\, D:/) + if (p.size() >= 3 && ((p[0] >= 'A' && p[0] <= 'Z') || + (p[0] >= 'a' && p[0] <= 'z')) && + p[1] == ':' && (p[2] == '/' || p[2] == '\\')) return true; + return false; +} + +std::string path_join(std::string_view a, std::string_view b) { + if (a.empty()) return std::string(b); + if (b.empty()) return std::string(a); + if (is_absolute_path(b)) return std::string(b); + if (a.back() == '/' || a.back() == '\\') return std::string(a) + std::string(b); + return std::string(a) + "/" + std::string(b); +} + +std::string path_join(std::string_view a, std::string_view b, std::string_view c) { + return path_join(path_join(a, b), c); +} + +std::string basename(std::string_view path) { + if (path.empty()) return ""; + // Strip trailing slashes + while (std::ssize(path) > 1 && path.back() == '/') + path.remove_suffix(1); + auto pos = str_rfind(path, '/'); + if (pos < 0) return std::string(path); + return std::string(path.substr(checked_cast(pos + 1))); +} + +std::string dirname(std::string_view path) { + if (path.empty()) return "."; + // Strip trailing slashes + while (std::ssize(path) > 1 && path.back() == '/') + path.remove_suffix(1); + auto pos = str_rfind(path, '/'); + if (pos < 0) return "."; + if (pos == 0) return "/"; + return std::string(path.substr(0, checked_cast(pos))); +} + +std::string strip_trailing_slash(std::string_view s) { + while (!s.empty() && s.back() == '/') + s.remove_suffix(1); + return s.empty() ? std::string("/") : std::string(s); +} + +std::string format_patch(const QuiltState &q, std::string_view name) { + if (!get_env("QUILT_PATCHES_PREFIX").empty()) { + return q.patches_dir + "/" + std::string(name); + } + return std::string(name); +} + +std::string trim(std::string_view s) { + while (!s.empty() && (s.front() == ' ' || s.front() == '\t' || + s.front() == '\r' || s.front() == '\n')) + s.remove_prefix(1); + while (!s.empty() && (s.back() == ' ' || s.back() == '\t' || + s.back() == '\r' || s.back() == '\n')) + s.remove_suffix(1); + return std::string(s); +} + +std::vector split_lines(std::string_view s) { + std::vector lines; + while (!s.empty()) { + auto pos = str_find(s, '\n'); + if (pos < 0) { + if (!s.empty() && s.back() == '\r') + s.remove_suffix(1); + lines.emplace_back(s); + break; + } + auto end = checked_cast(pos); + if (end > 0 && s[end - 1] == '\r') + --end; + lines.emplace_back(s.substr(0, end)); + s.remove_prefix(checked_cast(pos + 1)); + } + return lines; +} + + +std::vector split_on_whitespace(std::string_view s) { + std::vector tokens; + ptrdiff_t i = 0; + while (i < std::ssize(s)) { + while (i < std::ssize(s) && (s[checked_cast(i)] == ' ' || s[checked_cast(i)] == '\t')) + ++i; + if (i >= std::ssize(s)) break; + ptrdiff_t start = i; + while (i < std::ssize(s) && s[checked_cast(i)] != ' ' && s[checked_cast(i)] != '\t') + ++i; + tokens.emplace_back(s.substr(checked_cast(start), checked_cast(i - start))); + } + return tokens; +} + +static bool is_varname_start(char c) { + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '_'; +} + +static bool is_varname_char(char c) { + return is_varname_start(c) || (c >= '0' && c <= '9'); +} + +static std::string expand_var(std::string_view s, ptrdiff_t &i) { + // i points just past '$' + std::string name; + if (i < std::ssize(s) && s[checked_cast(i)] == '{') { + ++i; // skip '{' + while (i < std::ssize(s) && s[checked_cast(i)] != '}') { + name += s[checked_cast(i++)]; + } + if (i < std::ssize(s)) ++i; // skip '}' + } else { + while (i < std::ssize(s) && is_varname_char(s[checked_cast(i)])) { + name += s[checked_cast(i++)]; + } + } + if (name.empty()) return "$"; + return get_env(name); +} + +std::vector shell_split(std::string_view s) { + std::vector tokens; + ptrdiff_t i = 0; + while (i < std::ssize(s)) { + // Skip whitespace between tokens + while (i < std::ssize(s) && (s[checked_cast(i)] == ' ' || s[checked_cast(i)] == '\t')) + ++i; + if (i >= std::ssize(s)) break; + + std::string tok; + // Accumulate segments until unquoted whitespace + while (i < std::ssize(s) && s[checked_cast(i)] != ' ' && s[checked_cast(i)] != '\t') { + if (s[checked_cast(i)] == '\'') { + // Single-quoted: literal, no escapes, no variable expansion + ++i; + while (i < std::ssize(s) && s[checked_cast(i)] != '\'') + tok += s[checked_cast(i++)]; + if (i < std::ssize(s)) ++i; // skip closing ' + } else if (s[checked_cast(i)] == '"') { + // Double-quoted: backslash escapes and variable expansion + ++i; + while (i < std::ssize(s) && s[checked_cast(i)] != '"') { + if (s[checked_cast(i)] == '\\' && i + 1 < std::ssize(s)) { + char next = s[checked_cast(i + 1)]; + if (next == '"' || next == '\\' || next == '$') { + tok += next; + i += 2; + continue; + } + } + if (s[checked_cast(i)] == '$') { + ++i; + tok += expand_var(s, i); + continue; + } + tok += s[checked_cast(i++)]; + } + if (i < std::ssize(s)) ++i; // skip closing " + } else if (s[checked_cast(i)] == '$') { + // Unquoted variable expansion + ++i; + tok += expand_var(s, i); + } else if (s[checked_cast(i)] == '\\' && i + 1 < std::ssize(s)) { + // Unquoted backslash escape + tok += s[checked_cast(i + 1)]; + i += 2; + } else { + tok += s[checked_cast(i++)]; + } + } + if (!tok.empty()) { + tokens.push_back(std::move(tok)); + } + } + return tokens; +} + +std::vector read_series(std::string_view path, + std::map *strip_levels, + std::set *reversed) { + std::vector patches; + std::string content = read_file(path); + if (content.empty()) return patches; + auto lines = split_lines(content); + for (auto &line : lines) { + std::string trimmed = trim(line); + if (trimmed.empty()) continue; + if (trimmed[0] == '#') continue; + // Strip inline comments + auto hash = str_find(std::string_view(trimmed), " #"); + if (hash >= 0) { + trimmed = trim(std::string_view(trimmed).substr(0, checked_cast(hash))); + } + // Split into tokens + auto tokens = split_on_whitespace(trimmed); + if (tokens.empty()) continue; + std::string name = tokens[0]; + // Parse options (e.g., "-p0", "-p 0", "-R") + int strip = 1; + bool is_reversed = false; + for (ptrdiff_t i = 1; i < std::ssize(tokens); ++i) { + if (tokens[checked_cast(i)] == "-p" && i + 1 < std::ssize(tokens)) { + strip = checked_cast(parse_int(tokens[checked_cast(i + 1)])); + ++i; + } else if (tokens[checked_cast(i)].starts_with("-p") && std::ssize(tokens[checked_cast(i)]) > 2) { + strip = checked_cast(parse_int(tokens[checked_cast(i)].substr(2))); + } else if (tokens[checked_cast(i)] == "-R") { + is_reversed = true; + } + } + if (strip_levels && strip != 1) { + (*strip_levels)[name] = strip; + } + if (reversed && is_reversed) { + reversed->insert(name); + } + patches.push_back(std::move(name)); + } + return patches; +} + +bool write_series(std::string_view path, std::span patches, + const std::map &strip_levels, + const std::set &reversed) { + std::string content; + for (const auto &p : patches) { + content += p; + auto it = strip_levels.find(p); + if (it != strip_levels.end() && it->second != 1) { + content += " -p"; + content += std::to_string(it->second); + } + if (reversed.contains(p)) { + content += " -R"; + } + content += '\n'; + } + return write_file(path, content); +} + +std::vector read_applied(std::string_view path) { + std::vector patches; + std::string content = read_file(path); + if (content.empty()) return patches; + auto lines = split_lines(content); + for (auto &line : lines) { + std::string trimmed = trim(line); + if (!trimmed.empty()) { + patches.push_back(std::move(trimmed)); + } + } + return patches; +} + +bool write_applied(std::string_view path, std::span patches) { + std::string content; + for (const auto &p : patches) { + content += p; + content += '\n'; + } + return write_file(path, content); +} + +bool ensure_pc_dir(QuiltState &q) { + std::string pc = path_join(q.work_dir, q.pc_dir); + if (!is_directory(pc)) { + if (!make_dirs(pc)) { + err_line("Failed to create " + pc); + return false; + } + } + // Write .version + std::string version_path = path_join(pc, ".version"); + if (!file_exists(version_path)) { + if (!write_file(version_path, "2\n")) { + err_line("Failed to write " + version_path); + return false; + } + } + // Write .quilt_patches + std::string qp_path = path_join(pc, ".quilt_patches"); + if (!file_exists(qp_path)) { + if (!write_file(qp_path, q.patches_dir + "\n")) { + err_line("Failed to write " + qp_path); + return false; + } + } + // Write .quilt_series + std::string qs_path = path_join(pc, ".quilt_series"); + if (!file_exists(qs_path)) { + std::string series_name = get_env("QUILT_SERIES"); + if (series_name.empty()) series_name = "series"; + if (!write_file(qs_path, series_name + "\n")) { + err_line("Failed to write " + qs_path); + return false; + } + } + return true; +} + +// Parse a simplified subset of bash KEY=VALUE assignments from a quiltrc file. +// Supports: KEY=value, KEY="value", KEY='value', export KEY=value +// Skips comments (#), blank lines, and lines that aren't assignments. +static std::map parse_quiltrc(std::string_view content) { + std::map result; + auto lines = split_lines(content); + for (auto &line : lines) { + std::string_view sv = line; + // Strip leading whitespace + while (!sv.empty() && (sv.front() == ' ' || sv.front() == '\t')) + sv.remove_prefix(1); + // Skip blank lines and comments + if (sv.empty() || sv.front() == '#') continue; + // Strip optional "export " prefix + if (std::ssize(sv) > 7 && sv.substr(0, 7) == "export ") { + sv.remove_prefix(7); + while (!sv.empty() && (sv.front() == ' ' || sv.front() == '\t')) + sv.remove_prefix(1); + } + // Find '=' for KEY=VALUE + auto eq = str_find(sv, '='); + if (eq <= 0) continue; + // Validate key: must be alphanumeric/underscore + std::string_view key = sv.substr(0, checked_cast(eq)); + bool valid_key = true; + for (char c : key) { + if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == '_')) { + valid_key = false; + break; + } + } + if (!valid_key) continue; + // Parse value + std::string_view rest = sv.substr(checked_cast(eq + 1)); + std::string value; + if (!rest.empty() && rest.front() == '"') { + // Double-quoted: handle \" and \\ escapes + rest.remove_prefix(1); + while (!rest.empty() && rest.front() != '"') { + if (rest.front() == '\\' && std::ssize(rest) > 1) { + char next = rest[1]; + if (next == '"' || next == '\\') { + value += next; + rest.remove_prefix(2); + continue; + } + } + value += rest.front(); + rest.remove_prefix(1); + } + } else if (!rest.empty() && rest.front() == '\'') { + // Single-quoted: literal, no escapes + rest.remove_prefix(1); + while (!rest.empty() && rest.front() != '\'') { + value += rest.front(); + rest.remove_prefix(1); + } + } else { + // Unquoted: ends at whitespace, line ending, or # + while (!rest.empty() && rest.front() != ' ' && rest.front() != '\t' && + rest.front() != '\r' && rest.front() != '\n' && + rest.front() != '#') { + value += rest.front(); + rest.remove_prefix(1); + } + } + result[std::string(key)] = value; + } + return result; +} + +// Load and parse the quiltrc file. If quiltrc_path is empty, use default +// search order (~/.quiltrc, /etc/quilt.quiltrc). If "-", skip loading. +static std::map load_quiltrc(std::string_view quiltrc_path) { + if (quiltrc_path == "-") return {}; + + if (!quiltrc_path.empty()) { + std::string content = read_file(quiltrc_path); + if (!content.empty()) return parse_quiltrc(content); + return {}; + } + + // Default search order + std::string home = get_home_dir(); + if (!home.empty()) { + std::string user_rc = path_join(home, ".quiltrc"); + std::string content = read_file(user_rc); + if (!content.empty()) return parse_quiltrc(content); + } + + std::string sys_rc = get_system_quiltrc(); + std::string content = read_file(sys_rc); + if (!content.empty()) return parse_quiltrc(content); + + return {}; +} + +QuiltState load_state() { + QuiltState q; + q.patches_dir = "patches"; + q.pc_dir = ".pc"; + + // Read environment variable overrides + std::string env_pc = get_env("QUILT_PC"); + if (!env_pc.empty()) q.pc_dir = env_pc; + std::string env_patches = get_env("QUILT_PATCHES"); + if (!env_patches.empty()) q.patches_dir = env_patches; + + // Read QUILT_SERIES override for series filename + std::string env_series = get_env("QUILT_SERIES"); + + // Upward directory scan: find project root containing .pc/ or patches/ + // Only use relative paths for scanning; absolute paths are used as-is. + std::string cwd = get_cwd(); + std::string scan = cwd; + bool patches_dir_is_abs = is_absolute_path(q.patches_dir); + while (true) { + if (is_directory(path_join(scan, q.pc_dir)) || + (!patches_dir_is_abs && is_directory(path_join(scan, q.patches_dir)))) { + break; + } + std::string parent = dirname(scan); + if (parent == scan) { + // Reached filesystem root without finding anything; use cwd + scan = cwd; + break; + } + scan = parent; + } + q.work_dir = scan; + if (q.work_dir != cwd) { + // Compute subdirectory: strip work_dir prefix + '/' from cwd + q.subdir = cwd.substr(q.work_dir.size() + 1); + set_cwd(q.work_dir); + } + + // Check if .pc/ exists and read overrides + std::string pc_abs = path_join(q.work_dir, q.pc_dir); + std::string series_name_override; + if (is_directory(pc_abs)) { + // Read .quilt_patches override + std::string qp = trim(read_file(path_join(pc_abs, ".quilt_patches"))); + if (!qp.empty()) { + q.patches_dir = qp; + } + // Read .quilt_series override + std::string qs = trim(read_file(path_join(pc_abs, ".quilt_series"))); + if (!qs.empty()) { + series_name_override = qs; + } + } + + // Series file search order (when not overridden by .quilt_series) + if (q.series_file.empty()) { + std::string series_name = !series_name_override.empty() + ? series_name_override + : (env_series.empty() ? "series" : env_series); + std::string s1 = path_join(q.work_dir, series_name); + std::string s2 = path_join(q.work_dir, q.patches_dir, series_name); + std::string s3 = path_join(q.work_dir, q.pc_dir, series_name); + if (file_exists(s3)) { + q.series_file = path_join(q.pc_dir, series_name); + } else if (file_exists(s1)) { + q.series_file = series_name; + } else if (file_exists(s2)) { + q.series_file = path_join(q.patches_dir, series_name); + } else { + q.series_file = path_join(q.patches_dir, series_name); + } + } + + // Read series file + std::string series_abs = path_join(q.work_dir, q.series_file); + q.series_file_exists = file_exists(series_abs); + q.series = read_series(series_abs, &q.patch_strip_level, &q.patch_reversed); + + // Read applied-patches file + std::string applied_abs = path_join(q.work_dir, q.pc_dir, "applied-patches"); + q.applied = read_applied(applied_abs); + + return q; +} + +std::string pc_patch_dir(const QuiltState &q, std::string_view patch) { + return path_join(q.work_dir, q.pc_dir, patch); +} + +std::vector files_in_patch(const QuiltState &q, std::string_view patch) { + std::string dir = pc_patch_dir(q, patch); + if (!is_directory(dir)) return {}; + auto all = find_files_recursive(dir); + std::vector result; + for (auto &f : all) { + // Skip quilt metadata files (e.g. .timestamp, .needs_refresh) + auto slash = str_rfind(std::string_view(f), '/'); + std::string_view base = (slash >= 0) + ? std::string_view(f).substr(checked_cast(slash + 1)) + : std::string_view(f); + if (!base.empty() && base[0] == '.') continue; + result.push_back(std::move(f)); + } + return result; +} + +bool backup_file(QuiltState &q, std::string_view patch, std::string_view file) { + std::string src = path_join(q.work_dir, file); + std::string dst = path_join(pc_patch_dir(q, patch), file); + + // Ensure destination directory exists + std::string dst_dir = dirname(dst); + if (!is_directory(dst_dir)) { + if (!make_dirs(dst_dir)) { + err_line("Failed to create directory: " + dst_dir); + return false; + } + } + + if (file_exists(src)) { + return copy_file(src, dst); + } else { + // File doesn't exist yet; create an empty placeholder + return write_file(dst, ""); + } +} + +bool restore_file(QuiltState &q, std::string_view patch, std::string_view file) { + std::string backup = path_join(pc_patch_dir(q, patch), file); + std::string target = path_join(q.work_dir, file); + + if (!file_exists(backup)) { + err("No backup for "); err_line(file); + return false; + } + + std::string content = read_file(backup); + if (content.empty()) { + // Zero-length backup = file didn't exist before the patch — remove target + if (file_exists(target) && !delete_file(target)) { + err_line("Failed to remove " + target); + return false; + } + return true; + } + + // Ensure target directory exists + std::string target_dir = dirname(target); + if (!is_directory(target_dir)) { + if (!make_dirs(target_dir)) { + err_line("Failed to create directory: " + target_dir); + return false; + } + } + + return write_file(target, content); +} + +std::string to_cstr(std::string_view s) { + return std::string(s); +} + +static Command commands[] = { + {"new", cmd_new, + "Usage: quilt new [-p n] patchname\n" + "\n" + "Create a new empty patch and insert it after the topmost applied\n" + "patch in the series. The new patch becomes the top of the stack\n" + "immediately, but no patch file is written until quilt refresh.\n" + "\n" + "Options:\n" + " -p n Set the strip level for the patch (default: 1).\n", + "Create a new empty patch"}, + + {"add", cmd_add, + "Usage: quilt add [-P patch] file ...\n" + "\n" + "Register files with the topmost patch by backing up their current\n" + "contents. Files must be added before modification so that quilt\n" + "can capture the pre-change state. Use quilt edit to add and open\n" + "files in a single step.\n" + "\n" + "Options:\n" + " -P patch Add files to the named patch instead of the top.\n", + "Add files to the topmost patch"}, + + {"push", cmd_push, + "Usage: quilt push [-afqv] [--fuzz=N] [-m] [--merge[=merge|diff3]]\n" + " [--leave-rejects] [--refresh] [num|patch]\n" + "\n" + "Apply the next unapplied patch from the series. Without arguments,\n" + "applies one patch. With a patch name, applies patches up to and\n" + "including it. With a number, applies that many patches.\n" + "\n" + "Options:\n" + " -a Apply all unapplied patches.\n" + " -f Force apply even when the patch has rejects.\n" + " -q Quiet; print only error messages.\n" + " -v Verbose; pass --verbose to patch.\n" + " --fuzz=N Set the maximum fuzz factor for patch.\n" + " -m, --merge[=merge|diff3]\n" + " Merge using patch's merge mode.\n" + " --leave-rejects Leave .rej files in the working tree.\n" + " --refresh Refresh each patch after applying.\n", + "Apply patches to the source tree"}, + + {"pop", cmd_pop, + "Usage: quilt pop [-afRqv] [--refresh] [num|patch]\n" + "\n" + "Remove the topmost applied patch by restoring files from backup.\n" + "Without arguments, removes one patch. With a patch name, removes\n" + "patches until the named patch is on top. With a number, removes\n" + "that many patches.\n" + "\n" + "Options:\n" + " -a Remove all applied patches.\n" + " -f Force removal even if the patch needs refresh.\n" + " -R Always verify that the patch removes cleanly.\n" + " -q Quiet; print only error messages.\n" + " -v Verbose; print file-level restore messages.\n" + " --refresh Automatically refresh every patch before it gets unapplied.\n", + "Remove applied patches from the stack"}, + + {"refresh", cmd_refresh, + "Usage: quilt refresh [-p n] [-u | -U num | -c | -C num] [-z [new_name]]\n" + " [-f] [--no-timestamps] [--no-index] [--diffstat] [--sort]\n" + " [--strip-trailing-whitespace] [--backup]\n" + " [--diff-algorithm={myers|minimal|patience|histogram}] [patch]\n" + "\n" + "Regenerate the topmost or named patch by diffing backup copies in\n" + ".pc/ against the current working tree. This is what actually writes\n" + "the patch file; changes are not recorded until you refresh.\n" + "\n" + "Options:\n" + " -p n Set the path label style (0, 1, or ab).\n" + " -u Create a unified diff (default).\n" + " -U num Create a unified diff with num lines of context.\n" + " -c Create a context diff.\n" + " -C num Create a context diff with num lines of context.\n" + " -z [new_name] Create a new patch (fork) containing the changes;\n" + " the current patch is left as-is.\n" + " -f Refresh even when files are shadowed by patches\n" + " applied above.\n" + " --no-timestamps Omit timestamps from diff headers.\n" + " --no-index Omit Index: lines from the patch.\n" + " --diffstat Add a diffstat section to the patch header.\n" + " --sort Sort files alphabetically in the patch.\n" + " --strip-trailing-whitespace\n" + " Strip trailing whitespace from each line.\n" + " --backup Save the old patch file as name~ before updating.\n" + " --diff-algorithm=name\n" + " Select the diff algorithm: myers (default),\n" + " minimal, patience, or histogram.\n" + "\n" + "The QUILT_DIFF_ALGORITHM environment variable sets the default\n" + "algorithm (overridden by --diff-algorithm on the command line).\n", + "Regenerate a patch from working tree changes"}, + + {"diff", cmd_diff, + "Usage: quilt diff [-p n] [-u | -U num | -c | -C num]\n" + " [--combine patch] [-P patch] [-z] [-R] [--snapshot]\n" + " [--diff=utility] [--no-timestamps] [--no-index] [--sort]\n" + " [--diff-algorithm={myers|minimal|patience|histogram}] [file ...]\n" + "\n" + "Show the diff that quilt refresh would produce for the topmost or\n" + "named patch. Without -z, shows the full patch content (backup vs.\n" + "working tree). With -z, shows only uncommitted changes since the\n" + "last refresh.\n" + "\n" + "Options:\n" + " -p n Set the path label style (0, 1, or ab).\n" + " -u Create a unified diff (default).\n" + " -U num Create a unified diff with num lines of context.\n" + " -c Create a context diff.\n" + " -C num Create a context diff with num lines of context.\n" + " --combine patch Create a combined diff for all patches between\n" + " this patch and the topmost or specified patch.\n" + " A patch name of '-' is the first applied patch.\n" + " -P patch Show the diff for the named patch.\n" + " -z Show only changes since the last refresh.\n" + " -R Produce a reverse diff.\n" + " --snapshot Diff against a previously saved snapshot.\n" + " --diff=utility Use the specified diff utility instead of the\n" + " built-in diff engine.\n" + " --no-timestamps Omit timestamps from diff headers.\n" + " --no-index Omit Index: lines from the output.\n" + " --sort Sort files alphabetically in the output.\n" + " --diff-algorithm=name\n" + " Select the diff algorithm: myers (default),\n" + " minimal, patience, or histogram.\n" + "\n" + "The QUILT_DIFF_ALGORITHM environment variable sets the default\n" + "algorithm (overridden by --diff-algorithm on the command line).\n", + "Show the diff of the topmost or a specified patch"}, + + {"series", cmd_series, + "Usage: quilt series [-v]\n" + "\n" + "List all patches in the series file, both applied and unapplied.\n" + "\n" + "Options:\n" + " -v Mark applied patches with = and the top with =.\n", + "List all patches in the series"}, + + {"applied", cmd_applied, + "Usage: quilt applied [patch]\n" + "\n" + "List the currently applied patches in stack order. With a patch\n" + "name, lists all applied patches up to and including it.\n", + "List applied patches"}, + + {"unapplied", cmd_unapplied, + "Usage: quilt unapplied [patch]\n" + "\n" + "List the patches that have not been applied yet. With a patch\n" + "name, lists all patches after the named one in the series.\n", + "List patches not yet applied"}, + + {"top", cmd_top, + "Usage: quilt top\n" + "\n" + "Print the name of the topmost applied patch.\n", + "Show the topmost applied patch"}, + + {"next", cmd_next, + "Usage: quilt next [patch]\n" + "\n" + "Print the patch after the topmost applied patch, or after the\n" + "named patch in the series.\n", + "Show the next patch after the top or a given patch"}, + + {"previous", cmd_previous, + "Usage: quilt previous [patch]\n" + "\n" + "Print the patch before the topmost applied patch, or before the\n" + "named patch in the series.\n", + "Show the patch before the top or a given patch"}, + + {"delete", cmd_delete, + "Usage: quilt delete [-r] [--backup] [-n] [patch]\n" + "\n" + "Remove the topmost applied patch or a named unapplied patch from\n" + "the series. The patch file is kept unless -r is given.\n" + "\n" + "Options:\n" + " -r Remove the patch file as well.\n" + " --backup Rename the patch file to name~ instead of deleting.\n" + " -n Delete the next unapplied patch instead of the top.\n", + "Remove a patch from the series"}, + + {"rename", cmd_rename, + "Usage: quilt rename [-P patch] new_name\n" + "\n" + "Rename the topmost or named patch. Updates the series file and\n" + "renames the patch file in the patches directory.\n" + "\n" + "Options:\n" + " -P patch Rename the named patch instead of the top.\n", + "Rename a patch"}, + + {"import", cmd_import, + "Usage: quilt import [-p n] [-R] [-P name] [-f] [-d {o|a|n}] file ...\n" + "\n" + "Copy an external patch file into the patches directory and add it\n" + "to the series after the topmost applied patch. The patch is not\n" + "applied; use quilt push afterward.\n" + "\n" + "Options:\n" + " -p n Set the strip level for the imported patch.\n" + " -R Apply patch in reverse.\n" + " -P name Use this name instead of the original filename.\n" + " -f Overwrite if a patch with the same name exists.\n" + " -d {o|a|n} When overwriting: keep old, append all, or use\n" + " new header.\n", + "Import an external patch into the series"}, + + {"header", cmd_header, + "Usage: quilt header [-a|-r|-e] [--backup] [--dep3]\n" + " [--strip-diffstat] [--strip-trailing-whitespace] [patch]\n" + "\n" + "Print the header (description) of the topmost or named patch.\n" + "The header is all text in the patch file before the first diff.\n" + "\n" + "Options:\n" + " -a Append text from standard input to the header.\n" + " -r Replace the header with text from standard input.\n" + " -e Open the header in $EDITOR.\n" + " --backup Save the old patch file as name~ before modifying.\n" + " --dep3 Insert DEP-3 template when editing empty headers.\n" + " --strip-diffstat Remove the diffstat section from the header.\n" + " --strip-trailing-whitespace\n" + " Strip trailing whitespace from each header line.\n", + "Print or modify a patch header"}, + + {"files", cmd_files, + "Usage: quilt files [-v] [-a] [-l] [--combine patch] [patch]\n" + "\n" + "List the files that the topmost or named patch modifies.\n" + "\n" + "Options:\n" + " -v Show the patch name alongside each filename.\n" + " -a List files for all applied patches, not just one.\n" + " -l Add patch name to output lines.\n" + " --combine patch List files for a range of patches.\n", + "List files modified by a patch"}, + + {"patches", cmd_patches, + "Usage: quilt patches [-v] file ...\n" + "\n" + "List the patches that modify the given file or files. Searches\n" + "both applied patches (via .pc/ metadata) and unapplied patches\n" + "(by parsing patch files).\n" + "\n" + "Options:\n" + " -v Mark applied patches in the output.\n", + "List patches that modify a given file"}, + + {"edit", cmd_edit, + "Usage: quilt edit file ...\n" + "\n" + "Add files to the topmost patch and open them in $EDITOR. This is\n" + "a shortcut for quilt add followed by $EDITOR, and is the safest\n" + "way to modify tracked files.\n", + "Add files to the topmost patch and open an editor"}, + + {"revert", cmd_revert, + "Usage: quilt revert [-P patch] file ...\n" + "\n" + "Discard uncommitted changes to files by restoring them from the\n" + "backup copies in .pc/. Only reverts changes not yet captured by\n" + "quilt refresh.\n" + "\n" + "Options:\n" + " -P patch Revert files in the named patch instead of the top.\n", + "Discard working tree changes to files in a patch"}, + + {"remove", cmd_remove, + "Usage: quilt remove [-P patch] file ...\n" + "\n" + "Remove files from the topmost or named patch and restore them\n" + "from backup. The opposite of quilt add.\n" + "\n" + "Options:\n" + " -P patch Remove files from the named patch instead of the top.\n", + "Remove files from the topmost patch"}, + + {"fold", cmd_fold, + "Usage: quilt fold [-R] [-q] [-f] [-p n]\n" + "\n" + "Fold a diff read from standard input into the topmost patch.\n" + "Files touched by the incoming diff are automatically added to\n" + "the patch. Run quilt refresh afterward to update the patch file.\n" + "\n" + "Options:\n" + " -R Apply the diff in reverse.\n" + " -q Quiet; print only error messages.\n" + " -f Force apply even when the diff has rejects.\n" + " -p n Set the strip level for the incoming diff.\n", + "Fold a diff from stdin into the topmost patch"}, + + {"fork", cmd_fork, + "Usage: quilt fork [new_name]\n" + "\n" + "Copy the topmost patch to a new name. The series is updated to\n" + "reference the copy; the original file is kept but removed from\n" + "the series. If no name is given, -2 is appended (or -3, etc.).\n", + "Create a copy of the topmost patch under a new name"}, + + // Implemented analysis commands + {"annotate", cmd_annotate, + "Usage: quilt annotate [-P patch] file\n" + "\n" + "Show which applied patch last modified each line of a file,\n" + "similar to git blame. Works by comparing successive backup\n" + "copies in .pc/.\n" + "\n" + "Options:\n" + " -P patch Stop at the named patch instead of the top.\n", + "Show which patch modified each line of a file"}, + + {"graph", cmd_graph, + "Usage: quilt graph [--all] [--reduce] [--lines[=num]]\n" + " [--edge-labels=files] [patch]\n" + "\n" + "Print a dot-format dependency graph of applied patches. Two\n" + "patches are dependent if they modify the same file, or with\n" + "--lines, if their changes overlap.\n" + "\n" + "Options:\n" + " --all Include all applied patches (default: only\n" + " dependencies of the top or named patch).\n" + " --reduce Remove transitive edges from the graph.\n" + " --lines[=num] Compute line-level dependencies using num\n" + " lines of context (default: 2).\n" + " --edge-labels=files Label edges with shared filenames.\n", + "Print a dot dependency graph of applied patches"}, + + {"mail", cmd_mail, + "Usage: quilt mail {--mbox file} [--prefix prefix] [--sender addr]\n" + " [--from addr] [--to addr] [--cc addr] [--bcc addr]\n" + " [first_patch [last_patch]]\n" + "\n" + "Generate an mbox file containing one message per patch in the\n" + "given range. Output is intended for git am. Either --from or\n" + "--sender is required.\n" + "\n" + "Options:\n" + " --mbox file Write output to file (required).\n" + " --prefix prefix Subject line prefix (default: PATCH).\n" + " --sender addr Set the envelope sender address.\n" + " --from addr Set the From: header address.\n" + " --to addr Add a To: recipient (repeatable).\n" + " --cc addr Add a Cc: recipient (repeatable).\n" + " --bcc addr Add a Bcc: recipient (repeatable).\n", + "Generate an mbox file from a range of patches"}, + + // Stubs + {"grep", cmd_grep, + "Usage: quilt grep [-h|options] pattern\n" + "\n" + "Search source files, skipping patches/ and .pc/ directories.\n" + "Not yet implemented.\n", + "Search source files (not implemented)"}, + + {"setup", cmd_setup, + "Usage: quilt setup [-d path] series\n" + "\n" + "Initialize a source tree from a series file or RPM spec.\n" + "Not yet implemented.\n", + "Set up a source tree from a series file (not implemented)"}, + + {"shell", cmd_shell, + "Usage: quilt shell [command]\n" + "\n" + "Open a shell or run a command in the quilt environment.\n" + "Not yet implemented.\n", + "Open a subshell (not implemented)"}, + + {"snapshot", cmd_snapshot, + "Usage: quilt snapshot [-d]\n" + "\n" + "Save a copy of the current working tree state for later\n" + "comparison with quilt diff --snapshot.\n" + "\n" + "Options:\n" + " -d Remove the current snapshot instead of creating one.\n", + "Save a snapshot of the working tree for later diff"}, + + {"upgrade", cmd_upgrade, + "Usage: quilt upgrade\n" + "\n" + "Upgrade quilt metadata in .pc/ to the current format. This is\n" + "a no-op because only the version 2 format is supported.\n", + "Upgrade quilt metadata to the current format"}, + + {"init", cmd_init, + "Usage: quilt init\n" + "\n" + "Initialize quilt metadata in the current directory. This is\n" + "optional since any quilt command creates .pc/ and patches/ as\n" + "needed, but it lets you establish the project root before\n" + "working from a subdirectory.\n", + "Initialize quilt metadata in the current directory"}, +}; + +static constexpr int num_commands = sizeof(commands) / sizeof(commands[0]); + +static std::string to_upper(std::string_view s) { + std::string result(s); + for (char &c : result) { + if (c >= 'a' && c <= 'z') c -= 32; + } + return result; +} + +int quilt_main(int argc, char **argv) { + // --- Phase 1: Extract global options (--quiltrc) from argv --- + std::string quiltrc_path; // empty = default search, "-" = disabled + bool quiltrc_set = false; + std::vector clean_argv; + clean_argv.push_back(argv[0]); + for (int i = 1; i < argc; ++i) { + std::string_view a = argv[i]; + if (a == "--quiltrc" && i + 1 < argc) { + quiltrc_path = argv[i + 1]; + quiltrc_set = true; + ++i; // skip the argument + continue; + } + if (a.starts_with("--quiltrc=")) { + quiltrc_path = std::string(a.substr(10)); + quiltrc_set = true; + continue; + } + if (a == "--trace") { + continue; // accepted but ignored + } + clean_argv.push_back(argv[i]); + } + int clean_argc = checked_cast(std::ssize(clean_argv)); + + // Handle no arguments + if (clean_argc < 2) { + err_line("Usage: quilt [--quiltrc file] [options] [args]"); + err_line("Use \"quilt --help\" for a list of commands."); + return 1; + } + + std::string_view arg1 = clean_argv[1]; + + // Handle --version + if (arg1 == "--version" || arg1 == "-v") { + out_line(QUILT_VERSION); + return 0; + } + + // Handle --help + if (arg1 == "--help" || arg1 == "-h" || arg1 == "help") { + out_line("Usage: quilt [--quiltrc file] [options] [args]"); + out_line(""); + out_line("Commands:"); + ptrdiff_t max_len = 0; + for (int i = 0; i < num_commands; ++i) { + ptrdiff_t len = std::ssize(std::string_view(commands[i].name)); + if (len > max_len) max_len = len; + } + for (int i = 0; i < num_commands; ++i) { + std::string line = " "; + line += commands[i].name; + line.append(checked_cast(max_len + 2 - std::ssize(std::string_view(commands[i].name))), ' '); + line += commands[i].description; + out_line(line); + } + out_line(""); + out_line("Use \"quilt --help\" for details on a specific command."); + return 0; + } + + // --- Phase 2: Load quiltrc and populate environment --- + auto rc_vars = load_quiltrc(quiltrc_set ? quiltrc_path : std::string()); + // Apply quiltrc values to environment. GNU quilt sources quiltrc into the + // command process, so assignments there override inherited environment + // values unless the rc itself chooses otherwise. + for (auto &kv : rc_vars) { + set_env(kv.first, kv.second); + } + + // --- Phase 3: Find command --- + std::string cmd_name(arg1); + + // Find command (supports unique prefix abbreviation) + Command *found = nullptr; + int match_count = 0; + for (int i = 0; i < num_commands; ++i) { + if (cmd_name == commands[i].name) { + found = &commands[i]; + match_count = 1; + break; + } + if (std::string_view(commands[i].name).starts_with(cmd_name)) { + found = &commands[i]; + match_count++; + } + } + + if (match_count > 1) { + err_line("quilt: command '" + cmd_name + "' is ambiguous"); + return 1; + } + + if (!found) { + err_line("quilt: unknown command '" + cmd_name + "'"); + err_line("Use \"quilt --help\" for a list of commands."); + return 1; + } + + // Handle per-command -h/--help before dispatching + for (int i = 2; i < clean_argc; ++i) { + std::string_view a = clean_argv[checked_cast(i)]; + if (a == "-h" || a == "--help") { + out_line(found->usage); + return 0; + } + } + + // --- Phase 4: Load state --- + std::string original_cwd = get_cwd(); + QuiltState q = load_state(); + if (std::string_view(found->name) == "init" && get_cwd() != original_cwd) { + set_cwd(original_cwd); + } + q.config = rc_vars; + // Merge env overrides into config + for (auto &kv : rc_vars) { + std::string env_val = get_env(kv.first); + if (!env_val.empty()) { + q.config[kv.first] = env_val; + } + } + + // --- Phase 5: Inject QUILT_COMMAND_ARGS --- + std::string args_key = "QUILT_" + to_upper(found->name) + "_ARGS"; + std::string cmd_args = get_env(args_key); + auto extra_args = shell_split(cmd_args); + + // Build the final argv for the command: [cmd_name, extra_args..., user_args...] + // Command argv starts at clean_argv+1 + std::vector final_argv_storage; + std::vector final_argv; + + final_argv_storage.push_back(std::string(found->name)); + for (auto &ea : extra_args) { + final_argv_storage.push_back(ea); + } + for (int i = 2; i < clean_argc; ++i) { + final_argv_storage.push_back(clean_argv[checked_cast(i)]); + } + + for (auto &s : final_argv_storage) { + final_argv.push_back(const_cast(s.c_str())); + } + + // Dispatch + return found->fn(q, checked_cast(std::ssize(final_argv)), final_argv.data()); +} + +// === src/diff.cpp === + +// This is free and unencumbered software released into the public domain. +// +// Built-in diff engine: Myers, minimal, and patience algorithms. +// Produces unified or context diff output, replacing the need for an +// external diff binary. + +#include +#include +#include +#include + +std::optional parse_diff_algorithm(std::string_view name) +{ + if (name == "myers") return DiffAlgorithm::myers; + if (name == "minimal") return DiffAlgorithm::minimal; + if (name == "patience") return DiffAlgorithm::patience; + if (name == "histogram") return DiffAlgorithm::histogram; + return std::nullopt; +} + +// Approximate integer square root: next power of 2 >= sqrt(n). +// Matches libxdiff's xdl_bogosqrt(). +static ptrdiff_t bogosqrt(ptrdiff_t n) +{ + ptrdiff_t r = 1; + while (r * r < n) + r <<= 1; + return r; +} + +// Split content into lines, preserving the information about whether +// the file ended with a newline. Each element is one line WITHOUT its +// terminating '\n'. +struct FileLines { + std::vector lines; + bool has_trailing_newline = true; +}; + +static FileLines split_file_lines(std::string_view content) +{ + FileLines fl; + if (content.empty()) { + fl.has_trailing_newline = true; // empty file is fine + return fl; + } + + fl.has_trailing_newline = (content.back() == '\n'); + + ptrdiff_t start = 0; + ptrdiff_t len = std::ssize(content); + for (ptrdiff_t i = 0; i < len; ++i) { + if (content[checked_cast(i)] == '\n') { + fl.lines.push_back(content.substr(checked_cast(start), + checked_cast(i - start))); + start = i + 1; + } + } + // If there's content after the last newline (no trailing newline) + if (start < len) { + fl.lines.push_back(content.substr(checked_cast(start), + checked_cast(len - start))); + } + + return fl; +} + +// Myers diff algorithm. +// Returns a list of edit operations: 'E' (equal), 'D' (delete from old), +// 'I' (insert from new). +struct EditOp { + char type; // 'E', 'D', 'I' + ptrdiff_t old_idx; // index in old_lines (-1 for Insert) + ptrdiff_t new_idx; // index in new_lines (-1 for Delete) +}; + +// Backtrack through a Myers trace from (x,y) back to (0,0), building +// edit operations in reverse order. Returns ops in forward order. +static std::vector backtrack_trace( + const std::vector> &trace, + ptrdiff_t final_d, ptrdiff_t x, ptrdiff_t y, ptrdiff_t offset) +{ + std::vector ops; + + for (ptrdiff_t d = final_d; d > 0; --d) { + const auto &prev_v = trace[checked_cast(d)]; + ptrdiff_t k = x - y; + + ptrdiff_t prev_k; + if (k == -d || (k != d && prev_v[checked_cast(offset + k - 1)] < prev_v[checked_cast(offset + k + 1)])) { + prev_k = k + 1; // came from insert (down) + } else { + prev_k = k - 1; // came from delete (right) + } + + ptrdiff_t prev_x = prev_v[checked_cast(offset + prev_k)]; + ptrdiff_t prev_y = prev_x - prev_k; + + // Diagonal (equal lines) — add in reverse + while (x > prev_x && y > prev_y) { + --x; + --y; + ops.push_back({'E', x, y}); + } + + // The actual edit + if (x > prev_x) { + --x; + ops.push_back({'D', x, -1}); + } else if (y > prev_y) { + --y; + ops.push_back({'I', -1, y}); + } + } + + // Remaining diagonal at d=0 + while (x > 0 && y > 0) { + --x; + --y; + ops.push_back({'E', x, y}); + } + + std::ranges::reverse(ops); + return ops; +} + +static std::vector myers_diff( + std::span old_lines, + std::span new_lines, + DiffAlgorithm algorithm = DiffAlgorithm::myers) +{ + ptrdiff_t n = std::ssize(old_lines); + ptrdiff_t m = std::ssize(new_lines); + + // Trivial cases + if (n == 0 && m == 0) { + return {}; + } + if (n == 0) { + std::vector ops; + ops.reserve(checked_cast(m)); + for (ptrdiff_t j = 0; j < m; ++j) + ops.push_back({'I', -1, j}); + return ops; + } + if (m == 0) { + std::vector ops; + ops.reserve(checked_cast(n)); + for (ptrdiff_t i = 0; i < n; ++i) + ops.push_back({'D', i, -1}); + return ops; + } + + // Myers' algorithm with linear-space trace recording. + // We store the V array for each D step to reconstruct the path. + ptrdiff_t max_d = n + m; + // V is indexed by k + offset where k ranges from -max_d to max_d + ptrdiff_t offset = max_d; + ptrdiff_t v_size = 2 * max_d + 1; + + // Cost cap for myers mode (heuristic, matches libxdiff). + // minimal mode searches the full O(ND) space. + ptrdiff_t mxcost = max_d; + if (algorithm == DiffAlgorithm::myers) { + mxcost = bogosqrt(n + m); + if (mxcost < 256) mxcost = 256; + if (mxcost > max_d) mxcost = max_d; + } + + // Store a copy of V for each d value to reconstruct the edit path + std::vector> trace; + std::vector v(checked_cast(v_size), -1); + v[checked_cast(offset + 1)] = 0; + + ptrdiff_t final_d = -1; + for (ptrdiff_t d = 0; d <= max_d; ++d) { + trace.push_back(v); // save state before this round + for (ptrdiff_t k = -d; k <= d; k += 2) { + ptrdiff_t x; + if (k == -d || (k != d && v[checked_cast(offset + k - 1)] < v[checked_cast(offset + k + 1)])) { + x = v[checked_cast(offset + k + 1)]; // move down (insert) + } else { + x = v[checked_cast(offset + k - 1)] + 1; // move right (delete) + } + ptrdiff_t y = x - k; + + // Follow diagonal (equal lines) + while (x < n && y < m && old_lines[checked_cast(x)] == new_lines[checked_cast(y)]) { + ++x; + ++y; + } + + v[checked_cast(offset + k)] = x; + + if (x >= n && y >= m) { + final_d = d; + goto found; + } + } + + // Cost heuristic: if we've exceeded the budget, pick the + // furthest-reaching endpoint and construct a suboptimal script. + // This matches libxdiff's behavior for --diff-algorithm=myers. + if (d >= mxcost && d < max_d) { + // Find the diagonal with maximum progress (x + y) + ptrdiff_t best_x = -1, best_y = -1; + for (ptrdiff_t k = -d; k <= d; k += 2) { + ptrdiff_t x = v[checked_cast(offset + k)]; + if (x < 0) continue; + ptrdiff_t y = x - k; + if (x > n || y > m || y < 0) continue; + if (best_x < 0 || (x + y) > (best_x + best_y)) { + best_x = x; + best_y = y; + } + } + + if (best_x >= 0) { + // Backtrack from the best endpoint to (0,0) + final_d = d; + auto ops = backtrack_trace(trace, final_d, best_x, best_y, offset); + + // Recurse on the remaining portion with a fresh search + // budget, so matching lines past the cutoff are found. + auto tail_old = old_lines.subspan(checked_cast(best_x)); + auto tail_new = new_lines.subspan(checked_cast(best_y)); + auto tail_ops = myers_diff(tail_old, tail_new, algorithm); + + // Adjust indices back to the original coordinate space + for (auto &op : tail_ops) { + if (op.old_idx >= 0) op.old_idx += best_x; + if (op.new_idx >= 0) op.new_idx += best_y; + ops.push_back(op); + } + return ops; + } + } + } +found: + + return backtrack_trace(trace, final_d, n, m, offset); +} + +// Patience diff algorithm. +// +// Anchors on lines that appear exactly once in each file, computes their +// longest increasing subsequence (LIS) via patience sorting, and recurses +// on the gaps between anchors. Falls back to Myers (minimal) when no +// unique common lines exist. +static std::vector patience_diff( + std::span old_lines, + std::span new_lines) +{ + ptrdiff_t n = std::ssize(old_lines); + ptrdiff_t m = std::ssize(new_lines); + + if (n == 0 && m == 0) return {}; + if (n == 0) { + std::vector ops; + for (ptrdiff_t j = 0; j < m; ++j) + ops.push_back({'I', -1, j}); + return ops; + } + if (m == 0) { + std::vector ops; + for (ptrdiff_t i = 0; i < n; ++i) + ops.push_back({'D', i, -1}); + return ops; + } + + // Step 1: Match common prefix and suffix. + ptrdiff_t prefix = 0; + while (prefix < n && prefix < m && + old_lines[checked_cast(prefix)] == new_lines[checked_cast(prefix)]) + ++prefix; + + ptrdiff_t suffix = 0; + while (suffix < (n - prefix) && suffix < (m - prefix) && + old_lines[checked_cast(n - 1 - suffix)] == new_lines[checked_cast(m - 1 - suffix)]) + ++suffix; + + ptrdiff_t inner_old_len = n - prefix - suffix; + ptrdiff_t inner_new_len = m - prefix - suffix; + + // If prefix + suffix covers everything, no interior to diff. + if (inner_old_len <= 0 && inner_new_len <= 0) { + std::vector ops; + for (ptrdiff_t i = 0; i < n; ++i) + ops.push_back({'E', i, i}); + return ops; + } + + // Step 2: Find unique common lines in the interior. + // Map line content → (count_in_old, old_idx, count_in_new, new_idx). + struct LineInfo { + int old_count = 0; + ptrdiff_t old_idx = -1; + int new_count = 0; + ptrdiff_t new_idx = -1; + }; + std::unordered_map line_map; + + for (ptrdiff_t i = 0; i < inner_old_len; ++i) { + auto &info = line_map[old_lines[checked_cast(prefix + i)]]; + info.old_count++; + info.old_idx = i; // keeps last occurrence, but we only care when count==1 + } + for (ptrdiff_t j = 0; j < inner_new_len; ++j) { + auto &info = line_map[new_lines[checked_cast(prefix + j)]]; + info.new_count++; + info.new_idx = j; + } + + // Collect unique matches, sorted by old_idx (natural insertion order + // from scanning, but we sort explicitly to be safe). + struct Match { ptrdiff_t old_idx; ptrdiff_t new_idx; }; + std::vector unique_matches; + for (const auto &[line, info] : line_map) { + if (info.old_count == 1 && info.new_count == 1) { + unique_matches.push_back({info.old_idx, info.new_idx}); + } + } + std::ranges::sort(unique_matches, {}, &Match::old_idx); + + // Step 3: LIS of new_idx values via patience sorting. + // Each pile stores (new_idx, back_pointer into flat list). + struct Card { + ptrdiff_t new_idx; + ptrdiff_t old_idx; + ptrdiff_t back; // index into cards[] of predecessor, or -1 + }; + std::vector cards; + std::vector pile_tops; // indices into cards[] for top of each pile + + for (const auto &match : unique_matches) { + // Binary search: find leftmost pile whose top new_idx >= match.new_idx + ptrdiff_t lo = 0, hi = std::ssize(pile_tops); + while (lo < hi) { + ptrdiff_t mid = lo + (hi - lo) / 2; + if (cards[checked_cast(pile_tops[checked_cast(mid)])].new_idx < match.new_idx) + lo = mid + 1; + else + hi = mid; + } + + ptrdiff_t back = (lo > 0) ? pile_tops[checked_cast(lo - 1)] : ptrdiff_t{-1}; + ptrdiff_t card_idx = std::ssize(cards); + cards.push_back({match.new_idx, match.old_idx, back}); + + if (lo == std::ssize(pile_tops)) + pile_tops.push_back(card_idx); + else + pile_tops[checked_cast(lo)] = card_idx; + } + + // Reconstruct LIS by walking back-pointers from the last pile's top. + std::vector anchors; + if (!pile_tops.empty()) { + ptrdiff_t idx = pile_tops.back(); + while (idx >= 0) { + const auto &c = cards[checked_cast(idx)]; + anchors.push_back({c.old_idx, c.new_idx}); + idx = c.back; + } + std::ranges::reverse(anchors); + } + + // If no unique anchors found, fall back to Myers (minimal) on the interior. + if (anchors.empty()) { + auto inner_old = old_lines.subspan(checked_cast(prefix), + checked_cast(inner_old_len)); + auto inner_new = new_lines.subspan(checked_cast(prefix), + checked_cast(inner_new_len)); + auto inner_ops = myers_diff(inner_old, inner_new, DiffAlgorithm::minimal); + + // Assemble: prefix + inner + suffix + std::vector ops; + for (ptrdiff_t i = 0; i < prefix; ++i) + ops.push_back({'E', i, i}); + for (auto &op : inner_ops) { + if (op.old_idx >= 0) op.old_idx += prefix; + if (op.new_idx >= 0) op.new_idx += prefix; + ops.push_back(op); + } + for (ptrdiff_t i = 0; i < suffix; ++i) + ops.push_back({'E', n - suffix + i, m - suffix + i}); + return ops; + } + + // Step 4: Recurse on gaps between anchors. + std::vector ops; + + // Emit prefix + for (ptrdiff_t i = 0; i < prefix; ++i) + ops.push_back({'E', i, i}); + + ptrdiff_t prev_old = 0; // interior-relative + ptrdiff_t prev_new = 0; + + for (const auto &anchor : anchors) { + // Gap before this anchor + ptrdiff_t gap_old_len = anchor.old_idx - prev_old; + ptrdiff_t gap_new_len = anchor.new_idx - prev_new; + + if (gap_old_len > 0 || gap_new_len > 0) { + auto gap_old = old_lines.subspan(checked_cast(prefix + prev_old), + checked_cast(gap_old_len)); + auto gap_new = new_lines.subspan(checked_cast(prefix + prev_new), + checked_cast(gap_new_len)); + auto gap_ops = patience_diff(gap_old, gap_new); + for (auto &op : gap_ops) { + if (op.old_idx >= 0) op.old_idx += prefix + prev_old; + if (op.new_idx >= 0) op.new_idx += prefix + prev_new; + ops.push_back(op); + } + } + + // Emit the anchor as an equal match + ops.push_back({'E', prefix + anchor.old_idx, prefix + anchor.new_idx}); + prev_old = anchor.old_idx + 1; + prev_new = anchor.new_idx + 1; + } + + // Gap after last anchor + ptrdiff_t tail_old_len = inner_old_len - prev_old; + ptrdiff_t tail_new_len = inner_new_len - prev_new; + if (tail_old_len > 0 || tail_new_len > 0) { + auto tail_old = old_lines.subspan(checked_cast(prefix + prev_old), + checked_cast(tail_old_len)); + auto tail_new = new_lines.subspan(checked_cast(prefix + prev_new), + checked_cast(tail_new_len)); + auto tail_ops = patience_diff(tail_old, tail_new); + for (auto &op : tail_ops) { + if (op.old_idx >= 0) op.old_idx += prefix + prev_old; + if (op.new_idx >= 0) op.new_idx += prefix + prev_new; + ops.push_back(op); + } + } + + // Emit suffix + for (ptrdiff_t i = 0; i < suffix; ++i) + ops.push_back({'E', n - suffix + i, m - suffix + i}); + + return ops; +} + +// Histogram diff algorithm. +// +// Extends patience diff by relaxing the strict uniqueness requirement. +// Instead of only anchoring on lines unique in both files, histogram diff +// builds an occurrence-count index of the old file and finds the longest +// contiguous matching block anchored at the lowest-occurrence line. It +// then recurses on the regions before and after the block. Falls back to +// Myers (minimal) when no suitable anchor exists. +static constexpr int MAX_CHAIN_LENGTH = 64; +static constexpr int MAX_RECURSION = 1024; // same as Git's MAX_CNT_RECURSIVE + +static std::vector histogram_diff_impl( + std::span old_lines, + std::span new_lines, + int depth) +{ + ptrdiff_t n = std::ssize(old_lines); + ptrdiff_t m = std::ssize(new_lines); + + if (n == 0 && m == 0) return {}; + if (n == 0) { + std::vector ops; + for (ptrdiff_t j = 0; j < m; ++j) + ops.push_back({'I', -1, j}); + return ops; + } + if (m == 0) { + std::vector ops; + for (ptrdiff_t i = 0; i < n; ++i) + ops.push_back({'D', i, -1}); + return ops; + } + + // Step 1: Match common prefix and suffix. + ptrdiff_t prefix = 0; + while (prefix < n && prefix < m && + old_lines[checked_cast(prefix)] == new_lines[checked_cast(prefix)]) + ++prefix; + + ptrdiff_t suffix = 0; + while (suffix < (n - prefix) && suffix < (m - prefix) && + old_lines[checked_cast(n - 1 - suffix)] == new_lines[checked_cast(m - 1 - suffix)]) + ++suffix; + + ptrdiff_t inner_old_len = n - prefix - suffix; + ptrdiff_t inner_new_len = m - prefix - suffix; + + if (inner_old_len <= 0 && inner_new_len <= 0) { + std::vector ops; + for (ptrdiff_t i = 0; i < n; ++i) + ops.push_back({'E', i, i}); + return ops; + } + + // If recursion is too deep, fall back to Myers to avoid O(N²) behavior + // on files with many unique lines (where each split removes only one line). + if (depth >= MAX_RECURSION) { + auto inner_old = old_lines.subspan(checked_cast(prefix), + checked_cast(inner_old_len)); + auto inner_new = new_lines.subspan(checked_cast(prefix), + checked_cast(inner_new_len)); + auto inner_ops = myers_diff(inner_old, inner_new, DiffAlgorithm::minimal); + std::vector ops; + for (ptrdiff_t i = 0; i < prefix; ++i) + ops.push_back({'E', i, i}); + for (auto &op : inner_ops) { + if (op.old_idx >= 0) op.old_idx += prefix; + if (op.new_idx >= 0) op.new_idx += prefix; + ops.push_back(op); + } + for (ptrdiff_t i = 0; i < suffix; ++i) + ops.push_back({'E', n - suffix + i, m - suffix + i}); + return ops; + } + + // Step 2: Build histogram of old interior lines. + // Map line content → (occurrence count, list of positions in old). + // Also build a parallel count array for O(1) lookup during extension. + struct HistEntry { + int count = 0; + std::vector positions; // interior-relative positions in old + }; + std::unordered_map hist; + std::vector old_count(checked_cast(inner_old_len)); + + for (ptrdiff_t i = 0; i < inner_old_len; ++i) { + auto &entry = hist[old_lines[checked_cast(prefix + i)]]; + entry.count++; + entry.positions.push_back(i); + } + for (ptrdiff_t i = 0; i < inner_old_len; ++i) { + auto it = hist.find(old_lines[checked_cast(prefix + i)]); + old_count[checked_cast(i)] = it->second.count; + } + + // Step 3: Scan new interior lines to find the best contiguous matching block. + // Best block: lowest min-occurrence-count, then longest. + // + // Key optimizations vs. naive approach: + // (a) Skip lines in B whose A-count exceeds the current best threshold. + // (b) Skip A-occurrences whose count exceeds the threshold. + // (c) After extending a block forward, advance j past the extension so + // positions already covered are not re-scanned. + // (d) Use precomputed old_count[] instead of hash lookups during extension. + ptrdiff_t best_old_start = -1, best_new_start = -1, best_len = 0; + int best_threshold = MAX_CHAIN_LENGTH + 1; + + for (ptrdiff_t j = 0; j < inner_new_len; ) { + auto it = hist.find(new_lines[checked_cast(prefix + j)]); + if (it == hist.end()) { ++j; continue; } + const auto &entry = it->second; + if (entry.count > MAX_CHAIN_LENGTH || entry.count > best_threshold) { + ++j; continue; // (a) can't improve on current best + } + + ptrdiff_t j_advance = 1; // how far to advance j after this iteration + + for (ptrdiff_t pi = 0; pi < std::ssize(entry.positions); ++pi) { + ptrdiff_t oi = entry.positions[checked_cast(pi)]; + + // Extend backwards + ptrdiff_t back = 0; + int min_occ = entry.count; + while (oi - back - 1 >= 0 && j - back - 1 >= 0 && + old_lines[checked_cast(prefix + oi - back - 1)] == + new_lines[checked_cast(prefix + j - back - 1)]) { + ++back; + int occ = old_count[checked_cast(oi - back)]; + if (occ < min_occ) min_occ = occ; + } + + // Extend forwards (past the initial match at (oi, j)) + ptrdiff_t fwd = 0; + while (oi + fwd + 1 < inner_old_len && j + fwd + 1 < inner_new_len && + old_lines[checked_cast(prefix + oi + fwd + 1)] == + new_lines[checked_cast(prefix + j + fwd + 1)]) { + ++fwd; + int occ = old_count[checked_cast(oi + fwd)]; + if (occ < min_occ) min_occ = occ; + } + + ptrdiff_t block_len = back + 1 + fwd; + ptrdiff_t block_old_start = oi - back; + ptrdiff_t block_new_start = j - back; + + // (c) Skip j past forward extension to avoid re-scanning + if (fwd + 1 > j_advance) + j_advance = fwd + 1; + + // Accept if lower occurrence threshold, or same threshold but longer + if (min_occ < best_threshold || + (min_occ == best_threshold && block_len > best_len)) { + best_threshold = min_occ; + best_old_start = block_old_start; + best_new_start = block_new_start; + best_len = block_len; + } + } + j += j_advance; + } + + // Step 4: If no block found, fall back to Myers (minimal). + auto inner_old = old_lines.subspan(checked_cast(prefix), + checked_cast(inner_old_len)); + auto inner_new = new_lines.subspan(checked_cast(prefix), + checked_cast(inner_new_len)); + + if (best_len == 0) { + auto inner_ops = myers_diff(inner_old, inner_new, DiffAlgorithm::minimal); + std::vector ops; + for (ptrdiff_t i = 0; i < prefix; ++i) + ops.push_back({'E', i, i}); + for (auto &op : inner_ops) { + if (op.old_idx >= 0) op.old_idx += prefix; + if (op.new_idx >= 0) op.new_idx += prefix; + ops.push_back(op); + } + for (ptrdiff_t i = 0; i < suffix; ++i) + ops.push_back({'E', n - suffix + i, m - suffix + i}); + return ops; + } + + // Step 5: Recurse on regions before and after the matching block. + std::vector ops; + + // Emit prefix + for (ptrdiff_t i = 0; i < prefix; ++i) + ops.push_back({'E', i, i}); + + // Region before the block + if (best_old_start > 0 || best_new_start > 0) { + auto before_old = old_lines.subspan(checked_cast(prefix), + checked_cast(best_old_start)); + auto before_new = new_lines.subspan(checked_cast(prefix), + checked_cast(best_new_start)); + auto before_ops = histogram_diff_impl(before_old, before_new, depth + 1); + for (auto &op : before_ops) { + if (op.old_idx >= 0) op.old_idx += prefix; + if (op.new_idx >= 0) op.new_idx += prefix; + ops.push_back(op); + } + } + + // Emit the matching block as Equal ops + for (ptrdiff_t k = 0; k < best_len; ++k) { + ops.push_back({'E', prefix + best_old_start + k, + prefix + best_new_start + k}); + } + + // Region after the block + ptrdiff_t after_old_start = best_old_start + best_len; + ptrdiff_t after_new_start = best_new_start + best_len; + ptrdiff_t after_old_len = inner_old_len - after_old_start; + ptrdiff_t after_new_len = inner_new_len - after_new_start; + + if (after_old_len > 0 || after_new_len > 0) { + auto after_old = old_lines.subspan(checked_cast(prefix + after_old_start), + checked_cast(after_old_len)); + auto after_new = new_lines.subspan(checked_cast(prefix + after_new_start), + checked_cast(after_new_len)); + auto after_ops = histogram_diff_impl(after_old, after_new, depth + 1); + for (auto &op : after_ops) { + if (op.old_idx >= 0) op.old_idx += prefix + after_old_start; + if (op.new_idx >= 0) op.new_idx += prefix + after_new_start; + ops.push_back(op); + } + } + + // Emit suffix + for (ptrdiff_t i = 0; i < suffix; ++i) + ops.push_back({'E', n - suffix + i, m - suffix + i}); + + return ops; +} + +static std::vector histogram_diff( + std::span old_lines, + std::span new_lines) +{ + return histogram_diff_impl(old_lines, new_lines, 0); +} + +// A hunk groups consecutive edits with surrounding context lines. +struct Hunk { + ptrdiff_t old_start; // 1-based + ptrdiff_t old_count; + ptrdiff_t new_start; // 1-based + ptrdiff_t new_count; + std::vector ops; // the operations in this hunk (including context) +}; + +static std::vector build_hunks(const std::vector &ops, + ptrdiff_t context_lines) +{ + std::vector hunks; + if (ops.empty()) return hunks; + + // Find ranges of change (non-Equal) ops + struct ChangeRange { ptrdiff_t first; ptrdiff_t last; }; // inclusive indices into ops + std::vector changes; + for (ptrdiff_t i = 0; i < std::ssize(ops); ++i) { + if (ops[checked_cast(i)].type != 'E') { + if (changes.empty() || i > changes.back().last + 1) { + changes.push_back({i, i}); + } else { + changes.back().last = i; + } + } + } + + if (changes.empty()) return hunks; + + // Merge change ranges that overlap when context is added + std::vector merged; + merged.push_back(changes[0]); + for (ptrdiff_t i = 1; i < std::ssize(changes); ++i) { + // If the context windows overlap or are adjacent, merge + if (changes[checked_cast(i)].first - merged.back().last <= 2 * context_lines) { + merged.back().last = changes[checked_cast(i)].last; + } else { + merged.push_back(changes[checked_cast(i)]); + } + } + + // Build hunks from merged ranges + ptrdiff_t total_ops = std::ssize(ops); + for (const auto &range : merged) { + ptrdiff_t hunk_start = std::max(ptrdiff_t{0}, range.first - context_lines); + ptrdiff_t hunk_end = std::min(total_ops - 1, range.last + context_lines); + + Hunk h; + h.ops.assign(ops.begin() + hunk_start, ops.begin() + hunk_end + 1); + + // Compute old_start, old_count, new_start, new_count + h.old_count = 0; + h.new_count = 0; + h.old_start = 0; + h.new_start = 0; + bool first_old = true, first_new = true; + + for (const auto &op : h.ops) { + if (op.type == 'E') { + if (first_old) { h.old_start = op.old_idx + 1; first_old = false; } + if (first_new) { h.new_start = op.new_idx + 1; first_new = false; } + h.old_count++; + h.new_count++; + } else if (op.type == 'D') { + if (first_old) { h.old_start = op.old_idx + 1; first_old = false; } + h.old_count++; + } else { // 'I' + if (first_new) { h.new_start = op.new_idx + 1; first_new = false; } + h.new_count++; + } + } + + // If a side has no lines in the hunk (pure insert or pure delete), + // find the position from the preceding ops in the full ops list. + if (first_old) { + // Pure insert: old_start = last old line before insertion point + for (ptrdiff_t i = hunk_start - 1; i >= 0; --i) { + auto &prev = ops[checked_cast(i)]; + if (prev.old_idx >= 0) { + h.old_start = prev.old_idx + 1; // 1-based + break; + } + } + } + if (first_new) { + // Pure delete: new_start = last new line before deletion point + for (ptrdiff_t i = hunk_start - 1; i >= 0; --i) { + auto &prev = ops[checked_cast(i)]; + if (prev.new_idx >= 0) { + h.new_start = prev.new_idx + 1; // 1-based + break; + } + } + } + + hunks.push_back(std::move(h)); + } + + return hunks; +} + +// Format unified diff output +static std::string format_unified( + std::span old_lines, + std::span new_lines, + bool old_has_trailing_nl, + bool new_has_trailing_nl, + const std::vector &hunks, + std::string_view old_label, + std::string_view new_label) +{ + std::string result; + + // File headers + result += "--- "; + result += old_label; + result += '\n'; + result += "+++ "; + result += new_label; + result += '\n'; + + ptrdiff_t old_total = std::ssize(old_lines); + ptrdiff_t new_total = std::ssize(new_lines); + + for (const auto &hunk : hunks) { + // Hunk header: @@ -old_start[,old_count] +new_start[,new_count] @@ + if (hunk.old_count == 1 && hunk.new_count == 1) { + result += std::format("@@ -{} +{} @@\n", + hunk.old_start, hunk.new_start); + } else if (hunk.old_count == 1) { + result += std::format("@@ -{} +{},{} @@\n", + hunk.old_start, hunk.new_start, hunk.new_count); + } else if (hunk.new_count == 1) { + result += std::format("@@ -{},{} +{} @@\n", + hunk.old_start, hunk.old_count, hunk.new_start); + } else { + result += std::format("@@ -{},{} +{},{} @@\n", + hunk.old_start, hunk.old_count, + hunk.new_start, hunk.new_count); + } + + // Hunk body + for (const auto &op : hunk.ops) { + if (op.type == 'E') { + bool last_old = (op.old_idx == old_total - 1); + bool last_new = (op.new_idx == new_total - 1); + bool old_need_annot = last_old && !old_has_trailing_nl; + bool new_need_annot = last_new && !new_has_trailing_nl; + // When the trailing-newline annotation differs between + // sides, emit as D+I so each gets its own marker. + if (old_need_annot != new_need_annot) { + result += '-'; + result += old_lines[checked_cast(op.old_idx)]; + result += '\n'; + if (!old_has_trailing_nl) { + result += "\\ No newline at end of file\n"; + } + result += '+'; + result += new_lines[checked_cast(op.new_idx)]; + result += '\n'; + if (!new_has_trailing_nl) { + result += "\\ No newline at end of file\n"; + } + } else { + result += ' '; + result += old_lines[checked_cast(op.old_idx)]; + result += '\n'; + if (last_old && !old_has_trailing_nl && + last_new && !new_has_trailing_nl) { + result += "\\ No newline at end of file\n"; + } + } + } else if (op.type == 'D') { + result += '-'; + result += old_lines[checked_cast(op.old_idx)]; + result += '\n'; + // Check if this is the last old line with no trailing newline + if (op.old_idx == old_total - 1 && !old_has_trailing_nl) { + result += "\\ No newline at end of file\n"; + } + } else { // 'I' + result += '+'; + result += new_lines[checked_cast(op.new_idx)]; + result += '\n'; + // Check if this is the last new line with no trailing newline + if (op.new_idx == new_total - 1 && !new_has_trailing_nl) { + result += "\\ No newline at end of file\n"; + } + } + } + } + + return result; +} + +// Format context diff output +static std::string format_context( + std::span old_lines, + std::span new_lines, + bool old_has_trailing_nl, + bool new_has_trailing_nl, + const std::vector &hunks, + std::string_view old_label, + std::string_view new_label) +{ + std::string result; + + // File headers + result += "*** "; + result += old_label; + result += '\n'; + result += "--- "; + result += new_label; + result += '\n'; + + ptrdiff_t old_total = std::ssize(old_lines); + ptrdiff_t new_total = std::ssize(new_lines); + + for (const auto &hunk : hunks) { + result += "***************\n"; + + // Classify each edit group: adjacent D and I runs form "changes" (! prefix) + // We need to build old-side and new-side lines with proper prefixes. + struct SideLine { char prefix; std::string_view text; bool no_newline; }; + std::vector old_side, new_side; + + ptrdiff_t num_ops = std::ssize(hunk.ops); + for (ptrdiff_t k = 0; k < num_ops; ) { + const auto &op = hunk.ops[checked_cast(k)]; + if (op.type == 'E') { + bool onl = (op.old_idx == old_total - 1 && !old_has_trailing_nl && + op.new_idx == new_total - 1 && !new_has_trailing_nl); + old_side.push_back({' ', old_lines[checked_cast(op.old_idx)], onl}); + new_side.push_back({' ', new_lines[checked_cast(op.new_idx)], onl}); + ++k; + } else { + // Collect consecutive D then I runs + ptrdiff_t ds = k; + while (k < num_ops && hunk.ops[checked_cast(k)].type == 'D') ++k; + ptrdiff_t de = k; // exclusive + while (k < num_ops && hunk.ops[checked_cast(k)].type == 'I') ++k; + ptrdiff_t ie = k; // exclusive + + bool is_change = (de > ds && ie > de); + for (ptrdiff_t j = ds; j < de; ++j) { + auto &dop = hunk.ops[checked_cast(j)]; + bool onl = (dop.old_idx == old_total - 1 && !old_has_trailing_nl); + old_side.push_back({is_change ? '!' : '-', + old_lines[checked_cast(dop.old_idx)], onl}); + } + for (ptrdiff_t j = de; j < ie; ++j) { + auto &iop = hunk.ops[checked_cast(j)]; + bool onl = (iop.new_idx == new_total - 1 && !new_has_trailing_nl); + new_side.push_back({is_change ? '!' : '+', + new_lines[checked_cast(iop.new_idx)], onl}); + } + } + } + + // Old range header + ptrdiff_t oe = hunk.old_count == 0 ? hunk.old_start : hunk.old_start + hunk.old_count - 1; + result += std::format("*** {},{} ****\n", hunk.old_start, oe); + + // Print old-side lines only if there are changes (not just context) + bool has_old_changes = false; + for (const auto &sl : old_side) { + if (sl.prefix != ' ') { has_old_changes = true; break; } + } + if (has_old_changes) { + for (const auto &sl : old_side) { + result += sl.prefix; + result += ' '; + result += sl.text; + result += '\n'; + if (sl.no_newline) { + result += "\\ No newline at end of file\n"; + } + } + } + + // New range header + ptrdiff_t ne = hunk.new_count == 0 ? hunk.new_start : hunk.new_start + hunk.new_count - 1; + result += std::format("--- {},{} ----\n", hunk.new_start, ne); + + // Print new-side lines only if there are changes + bool has_new_changes = false; + for (const auto &sl : new_side) { + if (sl.prefix != ' ') { has_new_changes = true; break; } + } + if (has_new_changes) { + for (const auto &sl : new_side) { + result += sl.prefix; + result += ' '; + result += sl.text; + result += '\n'; + if (sl.no_newline) { + result += "\\ No newline at end of file\n"; + } + } + } + } + + return result; +} + +DiffResult builtin_diff(std::string_view old_path, std::string_view new_path, + int context_lines, + std::string_view old_label, std::string_view new_label, + DiffFormat format, + DiffAlgorithm algorithm, + std::map *fs) +{ + // Read files — treat /dev/null or non-existent as empty + std::string old_content, new_content; + bool old_is_null = (old_path == "/dev/null" || old_path.empty()); + bool new_is_null = (new_path == "/dev/null" || new_path.empty()); + + auto fs_exists = [&](std::string_view p) -> bool { + if (fs) return fs->contains(std::string(p)); + return file_exists(p); + }; + auto fs_read = [&](std::string_view p) -> std::string { + if (fs) { + auto it = fs->find(std::string(p)); + return it != fs->end() ? it->second : std::string{}; + } + return read_file(p); + }; + + if (!old_is_null && fs_exists(old_path)) { + old_content = fs_read(old_path); + } + if (!new_is_null && fs_exists(new_path)) { + new_content = fs_read(new_path); + } + + // Split into lines + auto old_fl = split_file_lines(old_content); + auto new_fl = split_file_lines(new_content); + + // Run diff algorithm + std::vector ops; + if (algorithm == DiffAlgorithm::patience) + ops = patience_diff(old_fl.lines, new_fl.lines); + else if (algorithm == DiffAlgorithm::histogram) + ops = histogram_diff(old_fl.lines, new_fl.lines); + else + ops = myers_diff(old_fl.lines, new_fl.lines, algorithm); + + // Check if there are any differences + bool has_diff = false; + for (const auto &op : ops) { + if (op.type != 'E') { has_diff = true; break; } + } + + // Also check trailing newline difference + if (!has_diff && !old_fl.lines.empty() && + old_fl.has_trailing_newline != new_fl.has_trailing_newline) { + has_diff = true; + } + + if (!has_diff) { + return {0, ""}; + } + + // When trailing newlines differ the last line must appear as a D+I + // pair (not a context 'E') so each side gets the right "\ No newline" + // annotation. Replace the trailing 'E' with D+I before building hunks + // so that build_hunks sees a real change and includes it in a hunk. + if (!old_fl.lines.empty() && + old_fl.has_trailing_newline != new_fl.has_trailing_newline) { + // Find the last 'E' op that covers the final line of both files + for (ptrdiff_t i = std::ssize(ops) - 1; i >= 0; --i) { + auto &op = ops[checked_cast(i)]; + if (op.type == 'E' && + op.old_idx == std::ssize(old_fl.lines) - 1 && + op.new_idx == std::ssize(new_fl.lines) - 1) { + // Replace with D then I + EditOp d_op{'D', op.old_idx, -1}; + EditOp i_op{'I', -1, op.new_idx}; + ops[checked_cast(i)] = d_op; + ops.insert(ops.begin() + i + 1, i_op); + break; + } + if (op.type == 'E') break; // only check the last equal op + } + } + + // Use labels or default to paths + std::string old_lbl = old_label.empty() ? std::string(old_path) : std::string(old_label); + std::string new_lbl = new_label.empty() ? std::string(new_path) : std::string(new_label); + + // Build hunks + auto hunks = build_hunks(ops, context_lines); + + std::string output; + if (format == DiffFormat::context) { + output = format_context(old_fl.lines, new_fl.lines, + old_fl.has_trailing_newline, + new_fl.has_trailing_newline, + hunks, old_lbl, new_lbl); + } else { + output = format_unified(old_fl.lines, new_fl.lines, + old_fl.has_trailing_newline, + new_fl.has_trailing_newline, + hunks, old_lbl, new_lbl); + } + + return {1, std::move(output)}; +} + +// === src/patch.cpp === + +// This is free and unencumbered software released into the public domain. +// +// Built-in patch engine for applying unified diffs. +// Implements spiral search with offset tracking, fuzz matching, +// reverse application, merge conflict markers, and reject files. + +#include +#include +#include + +// ── Patch parsing data structures ────────────────────────────────────── + +struct PatchHunk { + ptrdiff_t old_start = 0; // 1-based line from @@ header + ptrdiff_t old_count = 0; + ptrdiff_t new_start = 0; + ptrdiff_t new_count = 0; + std::vector lines; // prefixed with ' ', '+', '-' + // Flags for "\ No newline at end of file" on old/new side + bool old_no_newline = false; + bool new_no_newline = false; +}; + +struct PatchFile { + std::string old_path; + std::string new_path; + std::string target_path; // after strip-level + bool is_creation = false; // old = /dev/null + bool is_deletion = false; // new = /dev/null + std::vector hunks; +}; + +// ── Path stripping ───────────────────────────────────────────────────── + +// Strip N leading path components. Adjacent slashes count as one separator. +static std::string strip_path(std::string_view path, int strip) +{ + if (strip < 0) return std::string(path); + + std::string_view p = path; + for (int i = 0; i < strip && !p.empty(); ++i) { + // Skip to next slash + ptrdiff_t slash = str_find(p, '/'); + if (slash < 0) { + // No more slashes — strip everything + return std::string(p); + } + p = p.substr(checked_cast(slash) + 1); + // Skip consecutive slashes + while (!p.empty() && p[0] == '/') p = p.substr(1); + } + return std::string(p); +} + +// Extract filename from a --- or +++ header line. +// Strips trailing tab+timestamp if present. +static std::string extract_path(std::string_view line) +{ + // line is everything after "--- " or "+++ " + std::string_view rest = line; + ptrdiff_t tab = str_find(rest, '\t'); + if (tab >= 0) { + rest = rest.substr(0, checked_cast(tab)); + } + // Trim trailing whitespace + while (!rest.empty() && (rest.back() == ' ' || rest.back() == '\r')) { + rest = rest.substr(0, checked_cast(std::ssize(rest) - 1)); + } + return std::string(rest); +} + +// ── Unified diff parser ──────────────────────────────────────────────── + +// Parse a complete unified diff into a list of per-file patch descriptions. +static std::vector parse_patch(std::string_view text, int strip_level, + bool reverse) +{ + std::vector files; + auto lines = split_lines(text); + ptrdiff_t n = std::ssize(lines); + ptrdiff_t i = 0; + + while (i < n) { + // Look for "--- " header + if (!lines[checked_cast(i)].starts_with("--- ")) { + ++i; + continue; + } + + // Peek ahead for "+++ " + if (i + 1 >= n || !lines[checked_cast(i + 1)].starts_with("+++ ")) { + ++i; + continue; + } + + PatchFile pf; + std::string raw_old = extract_path(std::string_view(lines[checked_cast(i)]).substr(4)); + std::string raw_new = extract_path(std::string_view(lines[checked_cast(i + 1)]).substr(4)); + + if (reverse) { + std::swap(raw_old, raw_new); + } + + pf.old_path = raw_old; + pf.new_path = raw_new; + pf.is_creation = (raw_old == "/dev/null"); + pf.is_deletion = (raw_new == "/dev/null"); + + // Determine target path + // Prefer new path like GNU patch does for the common -p0 case + // where old has a .orig suffix (e.g., "--- f.txt.orig" / "+++ f.txt") + if (pf.is_creation) { + pf.target_path = strip_path(raw_new, strip_level); + } else if (pf.is_deletion) { + pf.target_path = strip_path(raw_old, strip_level); + } else { + std::string stripped_old = strip_path(raw_old, strip_level); + std::string stripped_new = strip_path(raw_new, strip_level); + // Use new path when old has .orig suffix, otherwise use + // the shorter path (GNU patch heuristic) + if (stripped_old.ends_with(".orig")) { + pf.target_path = stripped_new; + } else if (stripped_new.size() <= stripped_old.size()) { + pf.target_path = stripped_new; + } else { + pf.target_path = stripped_old; + } + } + + i += 2; // skip --- and +++ lines + + // Parse hunks + while (i < n && lines[checked_cast(i)].starts_with("@@ ")) { + PatchHunk hunk; + + // Parse @@ -old_start[,old_count] +new_start[,new_count] @@ + std::string_view hdr = std::string_view(lines[checked_cast(i)]); + ptrdiff_t at1 = str_find(hdr, '-', 3); + if (at1 < 0) { ++i; continue; } + + // Parse old range + ptrdiff_t pos = at1 + 1; + ptrdiff_t comma = str_find(hdr, ',', pos); + ptrdiff_t space = str_find(hdr, ' ', pos); + ptrdiff_t plus_pos = str_find(hdr, '+', pos); + + // Need '+' marker and a space or comma delimiter before it + if (plus_pos < 0) { ++i; continue; } + + if (comma >= 0 && comma < plus_pos && plus_pos - comma >= 2) { + hunk.old_start = parse_int(hdr.substr(checked_cast(pos), checked_cast(comma - pos))); + hunk.old_count = parse_int(hdr.substr(checked_cast(comma + 1), checked_cast(plus_pos - comma - 2))); + } else if (space >= 0 && space < plus_pos) { + hunk.old_start = parse_int(hdr.substr(checked_cast(pos), checked_cast(space - pos))); + hunk.old_count = 1; + } else { + ++i; continue; + } + + // Parse new range + pos = plus_pos + 1; + comma = str_find(hdr, ',', pos); + ptrdiff_t end_at = str_find(hdr, ' ', pos); + if (end_at < 0) end_at = std::ssize(hdr); + + if (end_at <= pos) { ++i; continue; } + + if (comma >= 0 && comma < end_at) { + hunk.new_start = parse_int(hdr.substr(checked_cast(pos), checked_cast(comma - pos))); + hunk.new_count = parse_int(hdr.substr(checked_cast(comma + 1), checked_cast(end_at - comma - 1))); + } else { + hunk.new_start = parse_int(hdr.substr(checked_cast(pos), checked_cast(end_at - pos))); + hunk.new_count = 1; + } + + if (reverse) { + std::swap(hunk.old_start, hunk.new_start); + std::swap(hunk.old_count, hunk.new_count); + } + + ++i; // skip @@ line + + // Collect hunk body + ptrdiff_t old_seen = 0, new_seen = 0; + while (i < n) { + std::string_view ln = lines[checked_cast(i)]; + + if (ln.starts_with("\\ No newline at end of file") || + ln.starts_with("\\ no newline at end of file")) { + // Applies to the preceding line + if (!hunk.lines.empty()) { + // Prefixes are already swapped if reverse=true, so + // '-' is always the old side and '+' the new side. + char prev_prefix = hunk.lines.back()[0]; + if (prev_prefix == '-') + hunk.old_no_newline = true; + else if (prev_prefix == '+') + hunk.new_no_newline = true; + else + hunk.old_no_newline = hunk.new_no_newline = true; + } + ++i; + continue; + } + + if (ln.empty()) { + // Empty line in diff = context line (space was stripped) + if (old_seen >= hunk.old_count && new_seen >= hunk.new_count) break; + std::string line_str = " "; + hunk.lines.push_back(line_str); + old_seen++; + new_seen++; + ++i; + continue; + } + + char prefix = ln[0]; + if (prefix == ' ' || prefix == '-' || prefix == '+') { + std::string line_str(ln); + + if (reverse) { + if (prefix == '-') line_str[0] = '+'; + else if (prefix == '+') line_str[0] = '-'; + } + + char actual_prefix = line_str[0]; + if (actual_prefix == ' ') { + if (old_seen >= hunk.old_count && new_seen >= hunk.new_count) break; + old_seen++; + new_seen++; + } else if (actual_prefix == '-') { + if (old_seen >= hunk.old_count) break; + old_seen++; + } else { // '+' + if (new_seen >= hunk.new_count) break; + new_seen++; + } + + hunk.lines.push_back(std::move(line_str)); + ++i; + } else { + // Start of next file section or unknown line + break; + } + } + + pf.hunks.push_back(std::move(hunk)); + } + + files.push_back(std::move(pf)); + } + + return files; +} + +// ── Line-based file representation ───────────────────────────────────── + +// Split file content into lines. Each line does NOT include its trailing '\n'. +// Returns whether the file had a trailing newline. +struct FileContent { + std::vector lines; + bool has_trailing_newline = true; + bool crlf = false; // true if original file used \r\n line endings +}; + +static FileContent load_file_lines(std::string_view content) +{ + FileContent fc; + if (content.empty()) { + fc.has_trailing_newline = true; + return fc; + } + + fc.has_trailing_newline = (content.back() == '\n'); + + // Detect \r\n from the first line ending + auto first_lf = str_find(content, '\n'); + if (first_lf > 0 && content[checked_cast(first_lf - 1)] == '\r') + fc.crlf = true; + + ptrdiff_t start = 0; + ptrdiff_t len = std::ssize(content); + for (ptrdiff_t i = 0; i < len; ++i) { + if (content[checked_cast(i)] == '\n') { + ptrdiff_t end = i; + if (end > start && content[checked_cast(end - 1)] == '\r') + --end; + fc.lines.emplace_back(content.substr(checked_cast(start), checked_cast(end - start))); + start = i + 1; + } + } + if (start < len) { + std::string tail(content.substr(checked_cast(start), checked_cast(len - start))); + if (!tail.empty() && tail.back() == '\r') + tail.pop_back(); + fc.lines.push_back(std::move(tail)); + } + + return fc; +} + +// ── Hunk matching ────────────────────────────────────────────────────── + +// Extract the context+deletion lines (the "old" side pattern) from a hunk. +// Returns pairs of (line_text, is_context) for matching purposes. +struct PatternLine { + std::string_view text; // line content (without prefix) + bool is_context; // true = context line, false = deletion line +}; + +static std::vector get_old_pattern(const PatchHunk &hunk) +{ + std::vector pattern; + for (const auto &line : hunk.lines) { + char prefix = line[0]; + std::string_view text(line); + text = text.substr(1); + if (prefix == ' ') { + pattern.push_back({text, true}); + } else if (prefix == '-') { + pattern.push_back({text, false}); + } + // '+' lines are not part of the old-side pattern + } + return pattern; +} + +// Count prefix and suffix context lines from the full hunk (including +/- +// lines). This gives the true context extent: prefix context is the number +// of ' ' lines before the first '+' or '-' line, and suffix context is the +// number of ' ' lines after the last '+' or '-' line. +struct HunkContext { + ptrdiff_t prefix = 0; + ptrdiff_t suffix = 0; +}; + +// Per-hunk fuzz amounts used when matching (for trimming during application). +struct HunkFuzz { + ptrdiff_t prefix = 0; + ptrdiff_t suffix = 0; +}; + +static HunkContext get_hunk_context(const PatchHunk &hunk) +{ + HunkContext ctx; + for (const auto &line : hunk.lines) { + if (line[0] == ' ') ++ctx.prefix; + else break; + } + for (auto it = hunk.lines.rbegin(); it != hunk.lines.rend(); ++it) { + if ((*it)[0] == ' ') ++ctx.suffix; + else break; + } + return ctx; +} + +// Try to match a hunk's old-side pattern against file lines starting at +// position `pos` (0-based), with `fuzz` context lines skipped at top/bottom. +// prefix_ctx/suffix_ctx are the real context extents from the full hunk. +// Returns true if the pattern matches. +static bool try_match(std::span file_lines, + ptrdiff_t pos, + const std::vector &pattern, + int fuzz, + ptrdiff_t prefix_ctx, + ptrdiff_t suffix_ctx) +{ + ptrdiff_t pat_len = std::ssize(pattern); + if (pat_len == 0) return true; + + ptrdiff_t prefix_fuzz = std::min(static_cast(fuzz), prefix_ctx); + ptrdiff_t suffix_fuzz = std::min(static_cast(fuzz), suffix_ctx); + + // Lines to match: skip prefix_fuzz from top, suffix_fuzz from bottom + ptrdiff_t match_start = prefix_fuzz; + ptrdiff_t match_end = pat_len - suffix_fuzz; + + // Adjust file position: we start matching at pos + prefix_fuzz + ptrdiff_t file_pos = pos + prefix_fuzz; + ptrdiff_t file_len = std::ssize(file_lines); + + for (ptrdiff_t j = match_start; j < match_end; ++j) { + if (file_pos < 0 || file_pos >= file_len) return false; + if (file_lines[checked_cast(file_pos)] != pattern[checked_cast(j)].text) return false; + ++file_pos; + } + + return true; +} + +// Spiral search: find where a hunk matches in the file. +// Returns the 0-based file position, or -1 if not found. +// Updates cumulative_offset on success. +static ptrdiff_t locate_hunk(std::span file_lines, + const PatchHunk &hunk, + const std::vector &pattern, + ptrdiff_t last_frozen_line, + ptrdiff_t cumulative_offset, + int max_fuzz) +{ + ptrdiff_t file_len = std::ssize(file_lines); + ptrdiff_t pat_old_count = std::ssize(pattern); + + // Get real prefix/suffix context from full hunk (not just old-side pattern) + auto ctx = get_hunk_context(hunk); + + // First guess: hunk header's old_start (1-based) converted to 0-based + offset + ptrdiff_t first_guess = hunk.old_start - 1 + cumulative_offset; + + // Clamp to valid range + ptrdiff_t max_pos = file_len - pat_old_count; + if (max_pos < 0) max_pos = 0; + + for (int fuzz = 0; fuzz <= max_fuzz; ++fuzz) { + ptrdiff_t prefix_fuzz = std::min(static_cast(fuzz), ctx.prefix); + ptrdiff_t suffix_fuzz = std::min(static_cast(fuzz), ctx.suffix); + ptrdiff_t effective_pat_len = pat_old_count - prefix_fuzz - suffix_fuzz; + + ptrdiff_t max_search = file_len - effective_pat_len; + if (effective_pat_len == 0) max_search = file_len; // empty pattern matches anywhere + + // Try exact position first + if (first_guess >= 0 && first_guess <= max_search && + first_guess > last_frozen_line - 1) { + if (try_match(file_lines, first_guess, pattern, fuzz, ctx.prefix, ctx.suffix)) { + return first_guess; + } + } + + // Spiral outward + ptrdiff_t max_offset_forward = max_search - first_guess; + ptrdiff_t max_offset_backward = first_guess - last_frozen_line; + ptrdiff_t max_range = std::max(max_offset_forward, max_offset_backward); + if (max_range < 0) max_range = 0; + + for (ptrdiff_t delta = 1; delta <= max_range; ++delta) { + // Try forward + ptrdiff_t pos = first_guess + delta; + if (pos >= 0 && pos <= max_search && pos > last_frozen_line - 1) { + if (try_match(file_lines, pos, pattern, fuzz, ctx.prefix, ctx.suffix)) { + return pos; + } + } + + // Try backward + pos = first_guess - delta; + if (pos >= 0 && pos <= max_search && pos > last_frozen_line - 1) { + if (try_match(file_lines, pos, pattern, fuzz, ctx.prefix, ctx.suffix)) { + return pos; + } + } + } + } + + return -1; // no match found +} + +// ── Hunk application ─────────────────────────────────────────────────── + +// Get the new-side (replacement) lines from a hunk. +static std::vector get_new_lines(const PatchHunk &hunk) +{ + std::vector result; + for (const auto &line : hunk.lines) { + char prefix = line[0]; + if (prefix == ' ' || prefix == '+') { + result.push_back(std::string_view(line).substr(1)); + } + } + return result; +} + +// Build the output file content after applying all successfully matched hunks. +// hunks_positions[i] = 0-based file position where hunk i matched, or -1 if rejected. +// hunk_fuzz[i] = fuzz amounts used for hunk i (to trim context from both sides). +static std::string build_output(std::span file_lines, + bool has_trailing_newline, + const PatchFile &pf, + const std::vector &hunk_positions, + const std::vector &hunk_fuzz) +{ + std::string output; + ptrdiff_t file_len = std::ssize(file_lines); + ptrdiff_t last_copied = 0; // next line to copy from input + + for (ptrdiff_t h = 0; h < std::ssize(pf.hunks); ++h) { + ptrdiff_t pos = hunk_positions[checked_cast(h)]; + if (pos < 0) continue; // rejected hunk, skip + + const auto &hunk = pf.hunks[checked_cast(h)]; + auto pattern = get_old_pattern(hunk); + ptrdiff_t pat_len = std::ssize(pattern); + auto new_lines = get_new_lines(hunk); + + // When fuzz was used, trim the fuzzed context lines from both sides. + // The fuzzed prefix/suffix context lines were not matched against the + // file, so we must not replace them. + auto fz = hunk_fuzz[checked_cast(h)]; + pos += fz.prefix; + pat_len -= fz.prefix + fz.suffix; + if (pat_len < 0) pat_len = 0; + ptrdiff_t new_start = fz.prefix; + ptrdiff_t new_end = std::ssize(new_lines) - fz.suffix; + if (new_end < new_start) new_end = new_start; + + // Clamp to file bounds + if (pos > file_len) pos = file_len; + + // Copy unchanged lines from last_copied to pos + for (ptrdiff_t j = last_copied; j < pos; ++j) { + output += file_lines[checked_cast(j)]; + output += '\n'; + } + + // Write replacement lines (trimmed by fuzz) + for (ptrdiff_t j = new_start; j < new_end; ++j) { + output += new_lines[checked_cast(j)]; + bool is_last_new_line = (j == new_end - 1); + if (is_last_new_line && fz.suffix == 0 && hunk.new_no_newline) { + // Don't add trailing newline (only when suffix not trimmed) + } else { + output += '\n'; + } + } + + last_copied = pos + pat_len; + if (last_copied > file_len) last_copied = file_len; + } + + // Copy remaining lines + for (ptrdiff_t j = last_copied; j < file_len; ++j) { + output += file_lines[checked_cast(j)]; + if (j < file_len - 1) { + output += '\n'; + } else { + // Last line: preserve original trailing newline status + // unless a hunk changed it + if (has_trailing_newline) { + output += '\n'; + } + } + } + + return output; +} + +// ── Merge conflict markers ───────────────────────────────────────────── + +// Build output with merge conflict markers for rejected hunks. +// Applies successful hunks normally, inserts conflict markers for failed ones. +static std::string build_merge_output(std::span file_lines, + bool has_trailing_newline, + const PatchFile &pf, + const std::vector &hunk_positions, + const std::vector &hunk_fuzz, + std::string_view merge_style) +{ + // For merge mode, we first apply successful hunks, then for rejected hunks + // we insert conflict markers at the hunk's expected position. + std::string output; + ptrdiff_t file_len = std::ssize(file_lines); + ptrdiff_t last_copied = 0; + + // Process all hunks in order + for (ptrdiff_t h = 0; h < std::ssize(pf.hunks); ++h) { + const auto &hunk = pf.hunks[checked_cast(h)]; + ptrdiff_t pos = hunk_positions[checked_cast(h)]; + + if (pos >= 0) { + // Successfully matched — apply normally + if (pos > file_len) pos = file_len; + auto pattern = get_old_pattern(hunk); + ptrdiff_t pat_len = std::ssize(pattern); + auto new_lines = get_new_lines(hunk); + + // Trim fuzzed context lines + auto fz = hunk_fuzz[checked_cast(h)]; + pos += fz.prefix; + pat_len -= fz.prefix + fz.suffix; + if (pat_len < 0) pat_len = 0; + ptrdiff_t new_start = fz.prefix; + ptrdiff_t new_end = std::ssize(new_lines) - fz.suffix; + if (new_end < new_start) new_end = new_start; + + if (pos > file_len) pos = file_len; + + for (ptrdiff_t j = last_copied; j < pos; ++j) { + output += file_lines[checked_cast(j)]; + output += '\n'; + } + for (ptrdiff_t j = new_start; j < new_end; ++j) { + output += new_lines[checked_cast(j)]; + bool is_last = (j == new_end - 1); + if (is_last && fz.suffix == 0 && hunk.new_no_newline) { + // no trailing newline + } else { + output += '\n'; + } + } + last_copied = pos + pat_len; + if (last_copied > file_len) last_copied = file_len; + } else { + // Rejected — insert per-change conflict markers at expected position + ptrdiff_t expected = hunk.old_start - 1; + if (expected < last_copied) expected = last_copied; + if (expected > file_len) expected = file_len; + + // Copy up to expected position + for (ptrdiff_t j = last_copied; j < expected; ++j) { + output += file_lines[checked_cast(j)]; + output += '\n'; + } + + // Walk through hunk lines, emitting context outside markers + // and changed regions inside markers. + ptrdiff_t file_pos = expected; + ptrdiff_t hi = 0; + ptrdiff_t hunk_len = std::ssize(hunk.lines); + + while (hi < hunk_len) { + char prefix = hunk.lines[checked_cast(hi)][0]; + + if (prefix == ' ') { + // Context line — emit the file's actual line + if (file_pos < file_len) { + output += file_lines[checked_cast(file_pos)]; + output += '\n'; + ++file_pos; + } + ++hi; + } else { + // Changed region — collect contiguous -/+ lines + std::vector old_lines, new_change; + while (hi < hunk_len && hunk.lines[checked_cast(hi)][0] == '-') { + old_lines.push_back(std::string_view(hunk.lines[checked_cast(hi)]).substr(1)); + ++hi; + } + while (hi < hunk_len && hunk.lines[checked_cast(hi)][0] == '+') { + new_change.push_back(std::string_view(hunk.lines[checked_cast(hi)]).substr(1)); + ++hi; + } + + output += "<<<<<<<\n"; + + // Current file content for the old-side span + ptrdiff_t span = std::ssize(old_lines); + ptrdiff_t end = file_pos + span; + if (end > file_len) end = file_len; + for (ptrdiff_t j = file_pos; j < end; ++j) { + output += file_lines[checked_cast(j)]; + output += '\n'; + } + + if (merge_style == "diff3") { + output += "|||||||\n"; + for (const auto &ol : old_lines) { + output += ol; + output += '\n'; + } + } + + output += "=======\n"; + for (const auto &nl : new_change) { + output += nl; + output += '\n'; + } + output += ">>>>>>>\n"; + + file_pos = end; + } + } + + last_copied = file_pos; + } + } + + // Copy remaining + for (ptrdiff_t j = last_copied; j < file_len; ++j) { + output += file_lines[checked_cast(j)]; + if (j < file_len - 1) { + output += '\n'; + } else if (has_trailing_newline) { + output += '\n'; + } + } + + return output; +} + +// ── Reject file generation ───────────────────────────────────────────── + +// Format rejected hunks as a unified diff .rej file. +static std::string format_rejects(const PatchFile &pf, + const std::vector &rejected) +{ + std::string result; + bool has_any = false; + + for (ptrdiff_t h = 0; h < std::ssize(pf.hunks); ++h) { + if (!rejected[checked_cast(h)]) continue; + const auto &hunk = pf.hunks[checked_cast(h)]; + + if (!has_any) { + // Write file headers + result += "--- "; + result += pf.old_path; + result += '\n'; + result += "+++ "; + result += pf.new_path; + result += '\n'; + has_any = true; + } + + // Write hunk header + result += std::format("@@ -{},{} +{},{} @@\n", + hunk.old_start, hunk.old_count, + hunk.new_start, hunk.new_count); + + // Write hunk lines + for (const auto &line : hunk.lines) { + result += line; + result += '\n'; + } + if (hunk.old_no_newline) { + result += "\\ No newline at end of file\n"; + } + } + + return result; +} + +// ── Main patch engine ────────────────────────────────────────────────── + +PatchResult builtin_patch(std::string_view patch_text, const PatchOptions &opts) +{ + PatchResult result; + result.exit_code = 0; + + // Filesystem abstraction: use in-memory map when opts.fs is set + auto fs_exists = [&](std::string_view p) -> bool { + if (opts.fs) return opts.fs->contains(std::string(p)); + return file_exists(p); + }; + auto fs_read = [&](std::string_view p) -> std::string { + if (opts.fs) { + auto it = opts.fs->find(std::string(p)); + return it != opts.fs->end() ? it->second : std::string{}; + } + return read_file(p); + }; + auto fs_write = [&](std::string_view p, std::string_view c) -> bool { + if (opts.fs) { (*opts.fs)[std::string(p)] = std::string(c); return true; } + return write_file(p, c); + }; + auto fs_delete = [&](std::string_view p) -> bool { + if (opts.fs) { opts.fs->erase(std::string(p)); return true; } + return delete_file(p); + }; + + auto files = parse_patch(patch_text, opts.strip_level, opts.reverse); + + if (files.empty()) { + return result; + } + + bool had_rejects = false; + + for (const auto &pf : files) { + if (pf.target_path.empty()) continue; + + if (!opts.quiet) { + result.out += "patching file " + pf.target_path + "\n"; + } + + // Load current file contents + FileContent fc; + bool file_existed = fs_exists(pf.target_path); + + if (pf.is_creation && file_existed) { + // File exists but patch says it should be new — still try to apply + } + + if (file_existed) { + fc = load_file_lines(fs_read(pf.target_path)); + } else if (!pf.is_creation) { + // File doesn't exist and this isn't a creation patch + result.err += "can't find file to patch at input line 0\n"; + if (!opts.force) { + result.exit_code = 1; + if (!opts.dry_run) { + had_rejects = true; + // Write all hunks as rejects + std::vector all_rejected(checked_cast(std::ssize(pf.hunks)), true); + std::string rej_content = format_rejects(pf, all_rejected); + if (!rej_content.empty()) { + fs_write(pf.target_path + ".rej", rej_content); + } + } + continue; + } + } + + // Try to match each hunk + std::vector hunk_positions(checked_cast(std::ssize(pf.hunks)), -1); + std::vector hunk_fuzz(checked_cast(std::ssize(pf.hunks))); + std::vector rejected(checked_cast(std::ssize(pf.hunks)), false); + ptrdiff_t cumulative_offset = 0; + ptrdiff_t last_frozen_line = 0; // 0-based, exclusive: lines before this are frozen + bool file_has_rejects = false; + + for (ptrdiff_t h = 0; h < std::ssize(pf.hunks); ++h) { + const auto &hunk = pf.hunks[checked_cast(h)]; + auto pattern = get_old_pattern(hunk); + + ptrdiff_t pos = locate_hunk(fc.lines, hunk, pattern, + last_frozen_line, cumulative_offset, + opts.fuzz); + + if (pos >= 0) { + hunk_positions[checked_cast(h)] = pos; + ptrdiff_t pat_len = std::ssize(pattern); + ptrdiff_t actual_offset = pos - (std::max(hunk.old_start, ptrdiff_t{1}) - 1); + auto ctx = get_hunk_context(hunk); + + // Determine fuzz level used for this hunk + int fuzz_used = 0; + if (opts.fuzz > 0) { + for (int f = 0; f <= opts.fuzz; ++f) { + if (try_match(fc.lines, pos, pattern, f, ctx.prefix, ctx.suffix)) { + fuzz_used = f; + break; + } + } + } + + // Record fuzz amounts for build_output trimming + hunk_fuzz[checked_cast(h)] = { + std::min(static_cast(fuzz_used), ctx.prefix), + std::min(static_cast(fuzz_used), ctx.suffix) + }; + auto &fz = hunk_fuzz[checked_cast(h)]; + + if (actual_offset != cumulative_offset && !opts.quiet) { + if (fuzz_used > 0) { + result.out += std::format( + "Hunk #{} succeeded at {} with fuzz {} (offset {} lines).\n", + h + 1, pos + 1, fuzz_used, actual_offset - cumulative_offset); + } else { + result.out += std::format( + "Hunk #{} succeeded at {} (offset {} lines).\n", + h + 1, pos + 1, actual_offset - cumulative_offset); + } + } else if (fuzz_used > 0 && !opts.quiet) { + result.out += std::format( + "Hunk #{} succeeded at {} with fuzz {}.\n", + h + 1, pos + 1, fuzz_used); + } + + // Update offset and frozen line (adjusted for fuzz) + cumulative_offset = actual_offset; + last_frozen_line = pos + pat_len - fz.suffix; + } else { + // Hunk failed + rejected[checked_cast(h)] = true; + file_has_rejects = true; + if (!opts.quiet) { + if (opts.merge) { + result.err += std::format("Hunk #{} NOT MERGED at {}.\n", + h + 1, hunk.old_start); + } else { + result.err += std::format("Hunk #{} FAILED at {}.\n", + h + 1, hunk.old_start); + } + } + } + } + + if (file_has_rejects) { + had_rejects = true; + result.exit_code = 1; + } + + // Apply changes + if (!opts.dry_run) { + bool any_applied = false; + for (ptrdiff_t h = 0; h < std::ssize(pf.hunks); ++h) { + if (hunk_positions[checked_cast(h)] >= 0) { any_applied = true; break; } + } + + if (any_applied || pf.is_creation || (opts.merge && file_has_rejects)) { + std::string new_content; + + if (opts.merge && file_has_rejects) { + new_content = build_merge_output(fc.lines, fc.has_trailing_newline, + pf, hunk_positions, hunk_fuzz, + opts.merge_style); + } else { + new_content = build_output(fc.lines, fc.has_trailing_newline, + pf, hunk_positions, hunk_fuzz); + } + + // Restore \r\n line endings if the original file used them + if (fc.crlf) { + std::string crlf_content; + crlf_content.reserve(new_content.size() + new_content.size() / 40); + for (size_t k = 0; k < new_content.size(); ++k) { + if (new_content[k] == '\n' && + (k == 0 || new_content[k - 1] != '\r')) { + crlf_content += '\r'; + } + crlf_content += new_content[k]; + } + new_content = std::move(crlf_content); + } + + // Create parent directories if needed + if (!opts.fs) { + std::string dir = dirname(pf.target_path); + if (!dir.empty() && dir != "." && !is_directory(dir)) { + make_dirs(dir); + } + } + + // Check if we should remove the file (-E flag) + if (opts.remove_empty && new_content.empty() && !pf.is_creation) { + if (file_existed) { + fs_delete(pf.target_path); + } + } else { + fs_write(pf.target_path, new_content); + } + } + + // Write reject file if needed (and not in merge mode) + if (file_has_rejects && !opts.merge) { + std::string rej_content = format_rejects(pf, rejected); + if (!rej_content.empty()) { + fs_write(pf.target_path + ".rej", rej_content); + } + if (!opts.quiet) { + ptrdiff_t rej_count = 0; + for (bool r : rejected) if (r) ++rej_count; + result.err += std::format( + "{} out of {} {} FAILED -- saving rejects to file {}.rej\n", + rej_count, std::ssize(pf.hunks), + std::ssize(pf.hunks) == 1 ? "hunk" : "hunks", + pf.target_path); + } + } + } + } + + if (had_rejects) { + result.exit_code = 1; + } + + return result; +} + +// === src/cmd_stack.cpp === + +// This is free and unencumbered software released into the public domain. +#include + + +static void write_applied_patches(QuiltState &q) { + std::string path = path_join(q.work_dir, q.pc_dir, "applied-patches"); + write_applied(path, q.applied); +} + +// Parse affected files from a unified diff, stripping path components +// to match what `patch -pN` would do. +static std::vector parse_patch_files(std::string_view content, int strip = 1) { + std::vector files; + auto lines = split_lines(content); + for (const auto &line : lines) { + if (!line.starts_with("+++ ")) continue; + std::string_view rest = std::string_view(line).substr(4); + // Skip /dev/null + if (rest.starts_with("/dev/null")) continue; + // Strip trailing tab and timestamp (e.g., "\t2024-01-01 ...") + ptrdiff_t tab = str_find(rest, '\t'); + if (tab >= 0) { + rest = rest.substr(0, checked_cast(tab)); + } + std::string f = trim(rest); + // Strip N leading path components (like patch -pN) + for (int i = 0; i < strip && !f.empty(); ++i) { + ptrdiff_t slash = str_find(std::string_view(f), '/'); + if (slash >= 0) { + f = f.substr(checked_cast(slash) + 1); + } + } + if (!f.empty()) { + files.push_back(std::move(f)); + } + } + return files; +} + +int cmd_series(QuiltState &q, int argc, char **argv) { + bool verbose = false; + // color: 0=never, 1=auto, 2=always + int color_mode = 0; + for (int i = 1; i < argc; ++i) { + std::string_view arg = argv[i]; + if (arg == "-v") { + verbose = true; + } else if (arg == "--color") { + color_mode = 1; // auto + } else if (arg.starts_with("--color=")) { + auto val = arg.substr(8); + if (val == "always") color_mode = 2; + else if (val == "auto") color_mode = 1; + else if (val == "never") color_mode = 0; + else { + err("Invalid --color value: "); err_line(val); + return 1; + } + } else if (arg[0] == '-') { + err("Unrecognized option: "); err_line(arg); + return 1; + } + } + + bool use_color = (color_mode == 2) || (color_mode == 1 && stdout_is_tty()); + + if (q.series.empty()) { + if (q.series_file_exists) { + // Empty series file: nothing to print, success + return 0; + } else { + err_line("No series file found"); + return 1; + } + } + + for (const auto &patch : q.series) { + if (verbose) { + if (!q.applied.empty() && patch == q.applied.back()) { + out("= "); + } else if (q.is_applied(patch)) { + out("+ "); + } else { + out(" "); + } + } + std::string name = format_patch(q, patch); + if (use_color) { + if (!q.applied.empty() && patch == q.applied.back()) { + out("\033[33m"); // yellow for top + } else if (q.is_applied(patch)) { + out("\033[32m"); // green for applied + } else { + out("\033[00m"); // default for unapplied + } + out(name); + out_line("\033[00m"); + } else { + out_line(name); + } + } + return 0; +} + +int cmd_applied(QuiltState &q, int argc, char **argv) { + std::string_view target; + for (int i = 1; i < argc; ++i) { + std::string_view arg = argv[i]; + if (arg[0] == '-') { + err("Unrecognized option: "); err_line(arg); + return 1; + } + target = strip_patches_prefix(q, arg); + } + + if (!target.empty()) { + // Print all applied patches up to and including target + auto idx = q.find_in_series(target); + if (!idx.has_value()) { + err("Patch "); err(format_patch(q, target)); err_line(" is not in series"); + return 1; + } + if (!q.is_applied(target)) { + err("Patch "); err(format_patch(q, target)); err_line(" is not applied"); + return 1; + } + for (const auto &a : q.applied) { + out_line(format_patch(q, a)); + if (a == target) break; + } + return 0; + } + + if (q.series.empty()) { + if (q.series_file_exists) { + err_line("No patches in series"); + } else { + err_line("No series file found"); + } + return 1; + } + if (q.applied.empty()) { + err_line("No patches applied"); + return 1; + } + + for (const auto &a : q.applied) { + out_line(format_patch(q, a)); + } + return 0; +} + +int cmd_unapplied(QuiltState &q, int argc, char **argv) { + std::string_view target; + for (int i = 1; i < argc; ++i) { + std::string_view arg = argv[i]; + if (arg[0] == '-') { + err("Unrecognized option: "); err_line(arg); + return 1; + } + target = strip_patches_prefix(q, arg); + } + + if (q.series.empty()) { + if (q.series_file_exists) { + err_line("No patches in series"); + } else { + err_line("No series file found"); + } + return 1; + } + + ptrdiff_t start_idx; + if (!target.empty()) { + auto idx = q.find_in_series(target); + if (!idx.has_value()) { + err("Patch "); err(format_patch(q, target)); err_line(" is not in series"); + return 1; + } + start_idx = idx.value() + 1; + } else { + ptrdiff_t top = q.top_index(); + start_idx = top + 1; + } + + if (start_idx >= std::ssize(q.series)) { + // With an explicit target patch, having no patches after it is not + // an error — just print nothing. + if (!target.empty()) { + return 0; + } + std::string_view top_name = q.applied.empty() ? std::string_view("??") : std::string_view(q.applied.back()); + err("File series fully applied, ends at patch "); err_line(format_patch(q, top_name)); + return 1; + } + + for (ptrdiff_t i = start_idx; i < std::ssize(q.series); ++i) { + out_line(format_patch(q, q.series[checked_cast(i)])); + } + return 0; +} + +int cmd_top(QuiltState &q, int argc, char **argv) { + for (int i = 1; i < argc; ++i) { + std::string_view arg = argv[i]; + if (arg[0] == '-') { + err("Unrecognized option: "); err_line(arg); + return 1; + } + } + if (q.series.empty()) { + if (q.series_file_exists) { + err_line("No patches in series"); + return 2; + } else { + err_line("No series file found"); + return 1; + } + } + if (q.applied.empty()) { + err_line("No patches applied"); + return 2; + } + out_line(format_patch(q, q.applied.back())); + return 0; +} + +int cmd_next(QuiltState &q, int argc, char **argv) { + std::string_view target; + for (int i = 1; i < argc; ++i) { + std::string_view arg = argv[i]; + if (arg[0] == '-') { + err("Unrecognized option: "); err_line(arg); + return 1; + } + target = strip_patches_prefix(q, arg); + } + + if (q.series.empty()) { + if (q.series_file_exists) { + err_line("No patches in series"); + return 2; + } else { + err_line("No series file found"); + return 1; + } + } + + ptrdiff_t after_idx; + if (!target.empty()) { + auto idx = q.find_in_series(target); + if (!idx.has_value()) { + err("Patch "); err(format_patch(q, target)); err_line(" is not in series"); + return 2; + } + // Original quilt: if the named patch is applied, error + if (q.is_applied(target)) { + err("Patch "); err(format_patch(q, target)); err_line(" is currently applied"); + return 2; + } + // If unapplied, return the patch itself (it's the "next" to be pushed) + out_line(format_patch(q, target)); + return 0; + } else { + ptrdiff_t top = q.top_index(); + after_idx = top + 1; + } + + if (after_idx >= std::ssize(q.series)) { + std::string_view top_name = q.applied.empty() ? std::string_view("??") : std::string_view(q.applied.back()); + err("File series fully applied, ends at patch "); err_line(format_patch(q, top_name)); + return 2; + } + + out_line(format_patch(q, q.series[checked_cast(after_idx)])); + return 0; +} + +int cmd_previous(QuiltState &q, int argc, char **argv) { + std::string_view target; + for (int i = 1; i < argc; ++i) { + std::string_view arg = argv[i]; + if (arg[0] == '-') { + err("Unrecognized option: "); err_line(arg); + return 1; + } + target = strip_patches_prefix(q, arg); + } + + if (!target.empty()) { + auto idx = q.find_in_series(target); + if (!idx.has_value()) { + err("Patch "); err(format_patch(q, target)); err_line(" is not in series"); + return 2; + } + if (idx.value() == 0) { + return 2; + } + out_line(format_patch(q, q.series[checked_cast(idx.value() - 1)])); + return 0; + } + + if (q.series.empty()) { + if (q.series_file_exists) { + err_line("No patches in series"); + return 2; + } else { + err_line("No series file found"); + return 1; + } + } + + if (q.applied.empty()) { + err_line("No patches applied"); + return 1; + } + + if (std::ssize(q.applied) == 1) { + return 2; + } + + out_line(format_patch(q, q.applied[checked_cast(std::ssize(q.applied) - 2)])); + return 0; +} + +int cmd_push(QuiltState &q, int argc, char **argv) { + bool push_all = false; + bool force = false; + bool quiet = false; + bool verbose = false; + int fuzz = -1; + bool merge = false; + std::string merge_style; + bool leave_rejects = false; + bool do_refresh = false; + int push_count = -1; + std::string_view target; + + for (int i = 1; i < argc; ++i) { + std::string_view arg = argv[i]; + if (arg == "-a") { push_all = true; } + else if (arg == "-f") { force = true; } + else if (arg == "-q" || arg == "--quiet") { quiet = true; } + else if (arg == "-v" || arg == "--verbose") { verbose = true; } + else if (arg.starts_with("--fuzz=")) { fuzz = checked_cast(parse_int(arg.substr(7))); } + else if (arg == "-m" || arg == "--merge") { merge = true; } + else if (arg.starts_with("--merge=")) { merge = true; merge_style = std::string(arg.substr(8)); } + else if (arg == "--leave-rejects") { leave_rejects = true; } + else if (arg == "--refresh") { do_refresh = true; } + else if (arg == "--color" || arg.starts_with("--color=")) { + if (arg.starts_with("--color=")) { + auto val = arg.substr(8); + if (val != "always" && val != "auto" && val != "never") { + err("Invalid --color value: "); err_line(val); + return 1; + } + } + } + else if (arg[0] == '-') { + err("Unrecognized option: "); err_line(arg); + return 1; + } + else { + // Try as number first + int val = 0; + auto [ptr, ec] = std::from_chars(arg.data(), arg.data() + arg.size(), val); + if (ec == std::errc{} && ptr == arg.data() + arg.size() && val > 0) { + push_count = val; + } else { + target = strip_patches_prefix(q, arg); + } + } + } + + // Refuse to push if top patch needs refresh (was force-applied) + if (!q.applied.empty()) { + std::string nr = path_join(pc_patch_dir(q, q.applied.back()), ".needs_refresh"); + if (file_exists(nr)) { + err_line("The topmost patch " + patch_path_display(q, q.applied.back()) + + " needs to be refreshed first."); + return 1; + } + } + + ptrdiff_t top = q.top_index(); + ptrdiff_t start_idx = top + 1; + + if (q.series.empty()) { + if (q.series_file_exists) { + err_line("No patches in series"); + } else { + err_line("No series file found"); + } + return 2; + } + + if (start_idx >= std::ssize(q.series)) { + err_line("File series fully applied, ends at patch " + + patch_path_display(q, q.applied.back())); + return 2; + } + + ptrdiff_t end_idx; // inclusive + if (push_all) { + end_idx = std::ssize(q.series) - 1; + } else if (!target.empty()) { + auto idx = q.find_in_series(target); + if (!idx.has_value()) { + err("Patch "); err(format_patch(q, target)); err_line(" is not in series"); + return 1; + } + end_idx = idx.value(); + if (end_idx < start_idx) { + err("Patch "); err(format_patch(q, target)); err_line(" is currently applied"); + return 2; + } + } else if (push_count > 0) { + end_idx = start_idx + push_count - 1; + if (end_idx >= std::ssize(q.series)) { + end_idx = std::ssize(q.series) - 1; + } + } else { + end_idx = start_idx; + } + + if (!ensure_pc_dir(q)) return 1; + + // Read QUILT_PATCH_OPTS + auto extra_patch_opts = shell_split(get_env("QUILT_PATCH_OPTS")); + + std::string last_applied; + for (ptrdiff_t i = start_idx; i <= end_idx; ++i) { + const std::string &name = q.series[checked_cast(i)]; + std::string display = patch_path_display(q, name); + + if (i > start_idx) { + out_line(""); + } + out_line("Applying patch " + display); + + // Read patch file + std::string patch_path = path_join(q.work_dir, q.patches_dir, name); + std::string patch_content = read_file(patch_path); + if (patch_content.empty() && !file_exists(patch_path)) { + err_line("Patch " + display + " does not exist"); + return 1; + } + + // Parse affected files and back them up + int strip_level = q.get_strip_level(name); + auto affected = parse_patch_files(patch_content, strip_level); + std::string pc_dir = pc_patch_dir(q, name); + if (!is_directory(pc_dir)) { + make_dirs(pc_dir); + } + + for (const auto &file : affected) { + backup_file(q, name, file); + } + + // Apply the patch using built-in patch engine + PatchOptions patch_opts; + patch_opts.strip_level = strip_level; + patch_opts.remove_empty = true; + patch_opts.force = force; + if (q.patch_reversed.contains(name)) patch_opts.reverse = true; + if (fuzz >= 0) patch_opts.fuzz = fuzz; + if (merge) { + patch_opts.merge = true; + patch_opts.merge_style = merge_style; + } + // Parse QUILT_PATCH_OPTS for additional options + for (const auto &opt : extra_patch_opts) { + std::string_view o = opt; + if (o == "-R") patch_opts.reverse = true; + else if (o == "-f" || o == "--force") patch_opts.force = true; + else if (o == "-s") patch_opts.quiet = true; + else if (o == "-E") patch_opts.remove_empty = true; + else if (o.starts_with("--fuzz=")) { + patch_opts.fuzz = checked_cast(parse_int(o.substr(7))); + } + } + + // Print verbose file list ourselves instead of relying on + // patch --verbose, which is not available on busybox. + if (verbose && !quiet) { + for (const auto &file : affected) { + out_line("patching file " + file); + } + } + + // Suppress builtin_patch's own "patching file" messages when we do verbose ourselves + if (verbose) patch_opts.quiet = true; + + PatchResult result = builtin_patch(patch_content, patch_opts); + + if (!quiet && !verbose && !result.out.empty()) { + out(result.out); + } + + if (result.exit_code != 0) { + if (!result.err.empty()) { + err(result.err); + } + if (force) { + // Force-applied: record as applied but mark as needing refresh + q.applied.push_back(name); + write_applied_patches(q); + write_file(path_join(pc_dir, ".timestamp"), ""); + write_file(path_join(pc_dir, ".needs_refresh"), ""); + out_line("Applied patch " + display + " (forced; needs refresh)"); + return 1; + } else { + // Not forced: restore files from backups and clean up + for (const auto &file : affected) { + restore_file(q, name, file); + } + err_line("Patch " + display + " does not apply (enforce with -f)"); + if (!leave_rejects) { + for (const auto &file : affected) { + std::string rej = path_join(q.work_dir, file + ".rej"); + if (file_exists(rej)) { + delete_file(rej); + } + } + } + delete_dir_recursive(pc_dir); + return 1; + } + } + + // Record as applied + q.applied.push_back(name); + write_applied_patches(q); + + // Create .timestamp + write_file(path_join(pc_dir, ".timestamp"), ""); + + if (do_refresh) { + char arg0[] = "refresh"; + char *refresh_argv[] = {arg0, nullptr}; + int rr = cmd_refresh(q, 1, refresh_argv); + if (rr != 0) return rr; + } + + last_applied = name; + } + + if (!last_applied.empty()) { + if (!quiet) out_line(""); + out_line("Now at patch " + patch_path_display(q, last_applied)); + } + return 0; +} + +int cmd_pop(QuiltState &q, int argc, char **argv) { + bool pop_all = false; + bool force = false; + bool quiet = false; + [[maybe_unused]] bool verbose = false; // accepted for compat, pop is verbose by default + bool auto_refresh = false; + int pop_count = -1; + std::string_view target; + + for (int i = 1; i < argc; ++i) { + std::string_view arg = argv[i]; + if (arg == "-a") { pop_all = true; } + else if (arg == "-f") { force = true; } + else if (arg == "-q" || arg == "--quiet") { quiet = true; } + else if (arg == "-v" || arg == "--verbose") { verbose = true; } + else if (arg == "-R") { /* accepted for compat, always verified now */ } + else if (arg == "--refresh") { auto_refresh = true; } + else if (arg[0] == '-') { + err("Unrecognized option: "); err_line(arg); + return 1; + } + else { + // Try as number first + int val = 0; + auto [ptr, ec] = std::from_chars(arg.data(), arg.data() + arg.size(), val); + if (ec == std::errc{} && ptr == arg.data() + arg.size() && val > 0) { + pop_count = val; + } else { + target = strip_patches_prefix(q, arg); + } + } + } + + if (q.applied.empty()) { + if (!q.series_file_exists) { + err_line("No series file found"); + return 1; + } + err_line("No patch removed"); + return 2; + } + + ptrdiff_t stop_idx; // index in applied to stop BEFORE (exclusive); pop down to this + if (pop_all) { + stop_idx = 0; + } else if (!target.empty()) { + // Find target in applied list + ptrdiff_t found_idx = -1; + for (ptrdiff_t i = 0; i < std::ssize(q.applied); ++i) { + if (q.applied[checked_cast(i)] == target) { + found_idx = i; + break; + } + } + if (found_idx < 0) { + err("Patch "); err(format_patch(q, target)); err_line(" is not applied"); + return 1; + } + // Pop down to (but not including) the target patch + stop_idx = found_idx + 1; + if (stop_idx >= std::ssize(q.applied)) { + err_line("No patch removed"); + return 2; + } + } else if (pop_count > 0) { + stop_idx = std::ssize(q.applied) - pop_count; + if (stop_idx < 0) stop_idx = 0; + } else { + // Pop just the top patch + stop_idx = std::ssize(q.applied) - 1; + } + + // Pop from the top down to stop_idx + bool first_pop = true; + while (std::ssize(q.applied) > stop_idx) { + const std::string &name = q.applied.back(); + std::string display = patch_path_display(q, name); + + // Auto-refresh before popping if requested + if (auto_refresh) { + auto extra = shell_split(get_env("QUILT_REFRESH_ARGS")); + std::vector r_storage; + r_storage.push_back("refresh"); + for (auto &e : extra) r_storage.push_back(e); + std::vector r_argv; + for (auto &s : r_storage) r_argv.push_back(s.data()); + int rr = cmd_refresh(q, checked_cast(std::ssize(r_argv)), r_argv.data()); + if (rr != 0) { + err_line("Refresh of patch " + display + " failed, aborting pop"); + return 1; + } + } + + // Check if patch needs refresh (force-applied) and -f not given + std::string pc_dir = pc_patch_dir(q, name); + std::string nr = path_join(pc_dir, ".needs_refresh"); + if (file_exists(nr) && !force && !auto_refresh) { + err_line("Patch " + display + " needs to be refreshed first."); + return 1; + } + + // Check if patch removes cleanly (detects dirty/unrefreshed changes) + if (!force) { + std::string patch_path = path_join(q.work_dir, q.patches_dir, name); + std::string patch_content = read_file(patch_path); + if (!patch_content.empty()) { + int strip_level = q.get_strip_level(name); + PatchOptions verify_opts; + verify_opts.strip_level = strip_level; + verify_opts.reverse = true; + verify_opts.dry_run = true; + verify_opts.force = true; + verify_opts.quiet = true; + PatchResult vr = builtin_patch(patch_content, verify_opts); + if (vr.exit_code != 0) { + err_line("Patch " + display + + " does not remove cleanly (refresh it or enforce with -f)"); + err_line("Hint: `quilt diff -z' will show the pending changes."); + return 1; + } + } + } + + if (!first_pop) { + out_line(""); + } + out_line("Removing patch " + display); + first_pop = false; + + // Restore backed-up files + auto files = files_in_patch(q, name); + for (const auto &file : files) { + restore_file(q, name, file); + if (!quiet) { + // Show what happened to each file: "Removing" if the file + // was deleted (created by the patch), "Restoring" otherwise. + if (!file_exists(path_join(q.work_dir, file))) { + out_line("Removing " + file); + } else { + out_line("Restoring " + file); + } + } + } + + // Remove the backup directory + delete_dir_recursive(pc_dir); + + // Remove from applied list + q.applied.pop_back(); + write_applied_patches(q); + } + + if (!quiet) out_line(""); + if (q.applied.empty()) { + out_line("No patches applied"); + } else { + out_line("Now at patch " + patch_path_display(q, q.applied.back())); + } + return 0; +} + +// === src/cmd_annotate.cpp === + +// This is free and unencumbered software released into the public domain. + +#include + +namespace { + +struct AnnotateOptions { + std::string patch; + std::string file; +}; + +static bool path_has_content(std::string_view path) +{ + return file_exists(path) && !read_file(path).empty(); +} + +static std::vector read_lines(std::string_view path) +{ + if (!path_has_content(path)) { + return {}; + } + return split_lines(read_file(path)); +} + +static std::string next_patch_for_file(const QuiltState &q, + std::string_view patch, + std::string_view file) +{ + bool after_target = false; + for (const auto &applied : q.applied) { + if (after_target) { + auto tracked = files_in_patch(q, applied); + if (std::ranges::find(tracked, file) != tracked.end()) { + return applied; + } + } + if (applied == patch) { + after_target = true; + } + } + return ""; +} + +static std::vector reannotate_lines(std::span old_lines, + std::span old_annotations, + std::span new_lines, + std::string_view annotation) +{ + const ptrdiff_t m = std::ssize(old_lines); + const ptrdiff_t n = std::ssize(new_lines); + std::vector> dp(checked_cast(m + 1), std::vector(checked_cast(n + 1), 0)); + + for (ptrdiff_t i = m; i-- > 0;) { + for (ptrdiff_t j = n; j-- > 0;) { + if (old_lines[checked_cast(i)] == new_lines[checked_cast(j)]) { + dp[checked_cast(i)][checked_cast(j)] = dp[checked_cast(i + 1)][checked_cast(j + 1)] + 1; + } else { + dp[checked_cast(i)][checked_cast(j)] = std::max(dp[checked_cast(i + 1)][checked_cast(j)], dp[checked_cast(i)][checked_cast(j + 1)]); + } + } + } + + std::vector result; + result.reserve(checked_cast(n)); + ptrdiff_t i = 0; + ptrdiff_t j = 0; + while (i < m || j < n) { + if (i < m && j < n && old_lines[checked_cast(i)] == new_lines[checked_cast(j)]) { + result.push_back(i < std::ssize(old_annotations) ? old_annotations[checked_cast(i)] : ""); + ++i; + ++j; + } else if (j < n && (i == m || dp[checked_cast(i)][checked_cast(j + 1)] >= dp[checked_cast(i + 1)][checked_cast(j)])) { + result.emplace_back(annotation); + ++j; + } else { + ++i; + } + } + return result; +} + +std::optional parse_options(const QuiltState &q, int argc, char **argv) +{ + AnnotateOptions opts; + for (int i = 1; i < argc; ++i) { + std::string_view arg = argv[i]; + if (arg == "-P" && i + 1 < argc) { + opts.patch = strip_patches_prefix(q, argv[i + 1]); + ++i; + continue; + } + if (!arg.empty() && arg[0] == '-') { + return std::nullopt; + } + if (!opts.file.empty()) { + return std::nullopt; + } + opts.file = subdir_path(q, arg); + } + + if (opts.file.empty()) { + return std::nullopt; + } + return opts; +} + +int no_applied_patches_error(const QuiltState &q) +{ + if (!q.series_file_exists) { + err_line("No series file found"); + } else if (q.series.empty()) { + err_line("No patches in series"); + } else { + err_line("No patches applied"); + } + return 1; +} + +} // namespace + +int cmd_annotate(QuiltState &q, int argc, char **argv) +{ + auto opts = parse_options(q, argc, argv); + if (!opts.has_value()) { + err_line("Usage: quilt annotate [-P patch] file"); + return 1; + } + + if (q.applied.empty()) { + return no_applied_patches_error(q); + } + + std::string stop_patch = opts->patch.empty() ? q.applied.back() : opts->patch; + if (!q.find_in_series(stop_patch).has_value()) { + err_line("Patch " + stop_patch + " is not in series"); + return 1; + } + if (!q.is_applied(stop_patch)) { + err_line("Patch " + stop_patch + " is not applied"); + return 1; + } + + std::vector patches; + std::vector files; + std::string next_patch; + + for (const auto &patch : q.applied) { + std::string old_file = path_join(pc_patch_dir(q, patch), opts->file); + if (file_exists(old_file)) { + patches.push_back(patch); + files.push_back(old_file); + } + if (patch == stop_patch) { + next_patch = next_patch_for_file(q, stop_patch, opts->file); + break; + } + } + + if (next_patch.empty()) { + files.push_back(path_join(q.work_dir, opts->file)); + } else { + files.push_back(path_join(pc_patch_dir(q, next_patch), opts->file)); + } + + if (patches.empty()) { + std::string target = files.back(); + if (!file_exists(target)) { + err_line("File " + opts->file + " does not exist"); + return 1; + } + for (const auto &line : read_lines(target)) { + out("\t" + line + "\n"); + } + return 0; + } + + std::vector annotations(checked_cast(std::ssize(read_lines(files.front()))), ""); + for (ptrdiff_t i = 0; i < std::ssize(patches); ++i) { + annotations = reannotate_lines(read_lines(files[checked_cast(i)]), annotations, + read_lines(files[checked_cast(i + 1)]), + std::to_string(i + 1)); + } + + auto final_lines = read_lines(files.back()); + for (ptrdiff_t i = 0; i < std::ssize(annotations); ++i) { + std::string line = i < std::ssize(final_lines) ? final_lines[checked_cast(i)] : ""; + out(annotations[checked_cast(i)] + "\t" + line + "\n"); + } + + out("\n"); + for (ptrdiff_t i = 0; i < std::ssize(patches); ++i) { + out(std::to_string(i + 1) + "\t" + format_patch(q, patches[checked_cast(i)]) + "\n"); + } + return 0; +} + +// === src/cmd_patch.cpp === + +// This is free and unencumbered software released into the public domain. + +#include +#include +#include + +static std::string read_patch_header(std::string_view patch_path) { + std::string content = read_file(patch_path); + if (content.empty()) return ""; + + std::string header; + auto lines = split_lines(content); + for (const auto &line : lines) { + if (line.starts_with("Index:") || + line.starts_with("---") || + line.starts_with("diff ")) { + break; + } + header += line; + header += '\n'; + } + return header; +} + +int cmd_init(QuiltState &q, int argc, char **) { + if (argc != 1) { + err_line("Usage: quilt init"); + return 1; + } + + q.work_dir = get_cwd(); + q.pc_dir = ".pc"; + q.patches_dir = "patches"; + std::string env_pc = get_env("QUILT_PC"); + if (!env_pc.empty()) { + q.pc_dir = env_pc; + } + std::string env_patches = get_env("QUILT_PATCHES"); + if (!env_patches.empty()) { + q.patches_dir = env_patches; + } + std::string series_name = get_env("QUILT_SERIES"); + if (series_name.empty()) { + series_name = "series"; + } + q.series_file = path_join(q.patches_dir, series_name); + + if (!ensure_pc_dir(q)) { + return 1; + } + + std::string patches_abs = path_join(q.work_dir, q.patches_dir); + if (!is_directory(patches_abs)) { + if (!make_dirs(patches_abs)) { + err_line("Failed to create " + patches_abs); + return 1; + } + } + + std::string series_abs = path_join(q.work_dir, q.series_file); + if (!file_exists(series_abs)) { + if (!write_series(series_abs, {}, {}, {})) { + err_line("Failed to write series file."); + return 1; + } + } + + std::string applied_abs = path_join(q.work_dir, q.pc_dir, "applied-patches"); + if (!file_exists(applied_abs)) { + if (!write_applied(applied_abs, {})) { + err_line("Failed to write applied-patches."); + return 1; + } + } + + out_line("The quilt meta-data is now initialized."); + return 0; +} + +int cmd_new(QuiltState &q, int argc, char **argv) { + // Parse options + std::string patch_name; + std::string p_value; + int i = 1; // skip argv[0] which is "new" + while (i < argc) { + std::string_view arg = argv[i]; + if (arg == "-p" && i + 1 < argc) { + p_value = argv[i + 1]; + i += 2; + continue; + } + if (arg.starts_with("-p") && std::ssize(arg) > 2) { + p_value = std::string(arg.substr(2)); + i += 1; + continue; + } + // First non-option argument is the patch name + if (arg[0] != '-') { + patch_name = std::string(arg); + i += 1; + break; + } + err("Unrecognized option: "); err_line(arg); + return 1; + } + + if (patch_name.empty()) { + err_line("Usage: quilt new [-p n] patchname"); + return 1; + } + + // Verify patch doesn't already exist in series + if (q.find_in_series(patch_name).has_value()) { + err("Patch "); err(patch_name); err_line(" already exists in series."); + return 1; + } + + // Ensure .pc/ directory exists + if (!ensure_pc_dir(q)) return 1; + + // Ensure patches/ directory exists + std::string patches_abs = path_join(q.work_dir, q.patches_dir); + if (!is_directory(patches_abs)) { + if (!make_dirs(patches_abs)) { + err_line("Failed to create " + patches_abs); + return 1; + } + } + + // Insert patch name into series (after current top, or at beginning) + ptrdiff_t top_idx = q.top_index(); + if (!q.applied.empty() && top_idx < 0) { + err_line("The series file no longer matches the applied patches. Please run 'quilt pop -a'."); + return 1; + } + if (top_idx < 0) { + // No applied patches — insert at beginning + q.series.insert(q.series.begin(), patch_name); + } else { + // Insert after the current top + q.series.insert(q.series.begin() + top_idx + 1, patch_name); + } + + // Validate strip level + if (!p_value.empty() && p_value != "0" && p_value != "1") { + err_line("Cannot create patches with -p" + p_value + + ", please specify -p0 or -p1 instead"); + return 1; + } + + // Store strip level + if (!p_value.empty() && p_value != "1") { + q.patch_strip_level[patch_name] = checked_cast(parse_int(p_value)); + } + + // Write series file + std::string series_abs = path_join(q.work_dir, q.series_file); + if (!write_series(series_abs, q.series, q.patch_strip_level, q.patch_reversed)) { + err_line("Failed to write series file."); + return 1; + } + + // Add to applied list and write applied-patches + q.applied.push_back(patch_name); + std::string applied_abs = path_join(q.work_dir, q.pc_dir, "applied-patches"); + if (!write_applied(applied_abs, q.applied)) { + err_line("Failed to write applied-patches."); + return 1; + } + + // Create .pc// directory + std::string pc_dir = pc_patch_dir(q, patch_name); + if (!is_directory(pc_dir)) { + if (!make_dirs(pc_dir)) { + err_line("Failed to create " + pc_dir); + return 1; + } + } + + out_line("Patch " + patch_path_display(q, patch_name) + " is now on top"); + return 0; +} + +int cmd_add(QuiltState &q, int argc, char **argv) { + if (q.applied.empty()) { + if (!q.series_file_exists) { + err_line("No series file found"); + return 1; + } + err_line("No patches applied"); + return 1; + } + + // Parse options + std::string_view patch = q.applied.back(); + std::vector files; + int i = 1; + while (i < argc) { + std::string_view arg = argv[i]; + if (arg == "-P" && i + 1 < argc) { + patch = strip_patches_prefix(q, argv[i + 1]); + i += 2; + continue; + } + if (arg[0] != '-') { + files.push_back(subdir_path(q, arg)); + } else { + err("Unrecognized option: "); err_line(arg); + return 1; + } + i += 1; + } + + if (files.empty()) { + err_line("Usage: quilt add [-P patch] file ..."); + return 1; + } + + if (!q.is_applied(patch)) { + err("Patch "); err(format_patch(q, patch)); err_line(" is not applied"); + return 1; + } + + for (const auto &file : files) { + // Check if file is already tracked by this patch + std::string backup_path = path_join(pc_patch_dir(q, patch), file); + if (file_exists(backup_path)) { + err("File "); err(file); err(" is already in patch "); + err_line(patch_path_display(q, patch)); + return 2; + } + + // Check if file is modified by any patch applied after this one + bool found_patch = false; + for (const auto &ap : q.applied) { + if (!found_patch) { + if (ap == patch) found_patch = true; + continue; + } + std::string later_backup = path_join(pc_patch_dir(q, ap), file); + if (file_exists(later_backup)) { + err("File "); err(file); err(" modified by patch "); + err_line(patch_path_display(q, ap)); + return 1; + } + } + + // Backup the file + if (!backup_file(q, patch, file)) { + err("Failed to back up "); err_line(file); + return 1; + } + + out("File "); out(file); out(" added to patch "); + out_line(patch_path_display(q, patch)); + } + + return 0; +} + +int cmd_remove(QuiltState &q, int argc, char **argv) { + if (q.applied.empty()) { + err_line("No patches applied"); + return 1; + } + + // Parse options + std::string_view patch = q.applied.back(); + std::vector files; + int i = 1; + while (i < argc) { + std::string_view arg = argv[i]; + if (arg == "-P" && i + 1 < argc) { + patch = strip_patches_prefix(q, argv[i + 1]); + i += 2; + continue; + } + if (arg[0] != '-') { + files.push_back(subdir_path(q, arg)); + } else { + err("Unrecognized option: "); err_line(arg); + return 1; + } + i += 1; + } + + if (files.empty()) { + err_line("Usage: quilt remove [-P patch] file ..."); + return 1; + } + + if (!q.is_applied(patch)) { + err("Patch "); err(format_patch(q, patch)); err_line(" is not applied"); + return 1; + } + + for (const auto &file : files) { + // Check if file is tracked by this patch + std::string backup_path = path_join(pc_patch_dir(q, patch), file); + if (!file_exists(backup_path)) { + err("File "); err(file); err(" is not in patch "); + err_line(patch_path_display(q, patch)); + return 1; + } + + // Restore file from backup + if (!restore_file(q, patch, file)) { + err("Failed to restore "); err_line(file); + return 1; + } + + // Remove backup file + delete_file(backup_path); + + out("File "); out(file); out(" removed from patch "); + out_line(patch_path_display(q, patch)); + } + + return 0; +} + +int cmd_edit(QuiltState &q, int argc, char **argv) { + if (q.applied.empty()) { + err_line("No patches applied"); + return 1; + } + + std::vector files; + for (int i = 1; i < argc; ++i) { + std::string_view arg = argv[i]; + if (arg[0] == '-') { + err("Unrecognized option: "); err_line(arg); + return 1; + } + files.push_back(subdir_path(q, arg)); + } + + if (files.empty()) { + err_line("Usage: quilt edit file ..."); + return 1; + } + + std::string_view patch = q.applied.back(); + + // Add each file to the top patch if not already tracked + for (const auto &file : files) { + std::string backup_path = path_join(pc_patch_dir(q, patch), file); + if (!file_exists(backup_path)) { + if (!backup_file(q, patch, file)) { + err("Failed to back up "); err_line(file); + return 1; + } + out("File "); out(file); out(" added to patch "); + out_line(patch_path_display(q, patch)); + } + } + + // Get editor from environment + std::string editor = get_env("EDITOR"); + if (editor.empty()) { + editor = "vi"; + } + + // Launch editor with all files as arguments + std::vector cmd_argv; + cmd_argv.push_back(editor); + for (const auto &file : files) { + cmd_argv.push_back(path_join(q.work_dir, file)); + } + + return run_cmd_tty(cmd_argv); +} + +// Convert unified diff output to context diff format. This allows +// quilt to produce -c/-C output even when the external diff command +// (e.g. busybox) only supports unified format. +static std::string unified_to_context(std::string_view unified) +{ + auto lines = split_lines(unified); + std::string result; + ptrdiff_t n = std::ssize(lines); + ptrdiff_t i = 0; + + // File headers: unified --- becomes context ***, unified +++ becomes context --- + while (i < n && !lines[checked_cast(i)].starts_with("--- ")) ++i; + if (i < n) { result += "*** " + lines[checked_cast(i)].substr(4) + "\n"; ++i; } + if (i < n && lines[checked_cast(i)].starts_with("+++ ")) { + result += "--- " + lines[checked_cast(i)].substr(4) + "\n"; ++i; + } + + while (i < n) { + if (!lines[checked_cast(i)].starts_with("@@ ")) { ++i; continue; } + + // Parse @@ -os[,oc] +ns[,nc] @@ + int os = 0, oc = 1, ns = 0, nc = 1; + { + std::string_view hdr = lines[checked_cast(i)]; + ptrdiff_t at1 = str_find(hdr, '-', 3); + if (at1 >= 0) { + ptrdiff_t p = at1 + 1; + ptrdiff_t c = str_find(hdr, ',', p); + ptrdiff_t pp = str_find(hdr, '+', p); + if (pp >= 0) { + if (c >= 0 && c < pp) { + os = checked_cast(parse_int(hdr.substr(checked_cast(p), checked_cast(c - p)))); + oc = checked_cast(parse_int(hdr.substr(checked_cast(c + 1), checked_cast(pp - c - 2)))); + } else { + os = checked_cast(parse_int(hdr.substr(checked_cast(p), checked_cast(pp - p - 1)))); + } + p = pp + 1; + c = str_find(hdr, ',', p); + ptrdiff_t end = str_find(hdr, ' ', p); + if (end < 0) end = std::ssize(hdr); + if (c >= 0 && c < end) { + ns = checked_cast(parse_int(hdr.substr(checked_cast(p), checked_cast(c - p)))); + nc = checked_cast(parse_int(hdr.substr(checked_cast(c + 1), checked_cast(end - c - 1)))); + } else { + ns = checked_cast(parse_int(hdr.substr(checked_cast(p), checked_cast(end - p)))); + } + } + } + } + ++i; + + // Collect unified hunk body lines with their types + struct UL { char type; std::string text; }; + std::vector
    body; + while (i < n && !lines[checked_cast(i)].starts_with("@@ ")) { + std::string_view ln = lines[checked_cast(i)]; + body.push_back({ln.empty() ? ' ' : ln[0], + ln.empty() ? std::string{} : std::string(ln.substr(1))}); + ++i; + } + + // Build old-side and new-side lines with context-diff prefixes. + // Adjacent -/+ runs form "change" blocks and get '!' prefix. + std::vector> old_side, new_side; + for (ptrdiff_t k = 0; k < std::ssize(body); ) { + if (body[checked_cast(k)].type == ' ') { + old_side.push_back({' ', body[checked_cast(k)].text}); + new_side.push_back({' ', body[checked_cast(k)].text}); + ++k; + } else if (body[checked_cast(k)].type == '-') { + ptrdiff_t ds = k; + while (k < std::ssize(body) && body[checked_cast(k)].type == '-') ++k; + ptrdiff_t as = k; + while (k < std::ssize(body) && body[checked_cast(k)].type == '+') ++k; + bool change = (as > ds && k > as); + for (ptrdiff_t m = ds; m < as; ++m) + old_side.push_back({change ? '!' : '-', body[checked_cast(m)].text}); + for (ptrdiff_t m = as; m < k; ++m) + new_side.push_back({change ? '!' : '+', body[checked_cast(m)].text}); + } else if (body[checked_cast(k)].type == '+') { + new_side.push_back({'+', body[checked_cast(k)].text}); + ++k; + } else { + ++k; + } + } + + int oe = oc == 0 ? os : os + oc - 1; + int ne = nc == 0 ? ns : ns + nc - 1; + + result += "***************\n"; + result += std::format("*** {},{} ****\n", os, oe); + bool has_old = false; + for (auto &[p, t] : old_side) if (p != ' ') { has_old = true; break; } + if (has_old) + for (auto &[p, t] : old_side) + result += std::string(1, p) + " " + t + "\n"; + + result += std::format("--- {},{} ----\n", ns, ne); + bool has_new = false; + for (auto &[p, t] : new_side) if (p != ' ') { has_new = true; break; } + if (has_new) + for (auto &[p, t] : new_side) + result += std::string(1, p) + " " + t + "\n"; + } + + return result; +} + +static constexpr std::string_view SNAPSHOT_PATCH = ".snap"; + +static bool is_placeholder_copy(std::string_view path) +{ + return file_exists(path) && read_file(path).empty(); +} + +// Parse QUILT_DIFF_OPTS and extract context line count if present. +// Returns the context line count (-1 if not specified in opts). +static int parse_diff_opts_context(std::span opts) +{ + for (ptrdiff_t i = 0; i < std::ssize(opts); ++i) { + const auto &o = opts[checked_cast(i)]; + if (o.starts_with("-U") && std::ssize(o) > 2) { + return checked_cast(parse_int(std::string_view(o).substr(2))); + } + if (o == "-U" && i + 1 < std::ssize(opts)) { + return checked_cast(parse_int(opts[checked_cast(i + 1)])); + } + } + return -1; +} + +// p_format: "ab" for a/b labels, "0" for bare filenames, "1" (default) for dir.orig/dir +// Format a file modification time as "YYYY-MM-DD HH:MM:SS.000000000 +HHMM". +static std::string format_file_timestamp(std::string_view path) { + int64_t mt = file_mtime(path); + if (mt <= 0) return ""; + DateTime dt = local_time(mt); + int off_h = dt.utc_offset / 3600; + int off_m = (std::abs(dt.utc_offset) % 3600) / 60; + return std::format("\t{:04d}-{:02d}-{:02d} {:02d}:{:02d}:{:02d}.000000000 {:+03d}{:02d}", + dt.year, dt.month, dt.day, + dt.hour, dt.min, dt.sec, off_h, off_m); +} + +static std::string generate_path_diff(const QuiltState &q, + std::string_view file, + std::string_view old_path, + bool old_placeholder, + std::string_view new_path, + bool new_placeholder, + std::string_view p_format = "1", + bool reverse = false, + std::span diff_cmd_base = {}, + int context_lines = 3, + DiffFormat diff_format = DiffFormat::unified, + bool no_timestamps = false, + DiffAlgorithm diff_algorithm = DiffAlgorithm::myers) { + bool old_missing = old_path.empty() || !file_exists(old_path) || + (old_placeholder && is_placeholder_copy(old_path)); + bool new_missing = new_path.empty() || !file_exists(new_path) || + (new_placeholder && is_placeholder_copy(new_path)); + + // Detect binary files (null bytes in first 8 KB) + auto is_binary = [](std::string_view path) { + std::string data = read_file(path); + size_t check_len = data.size() < 8192 ? data.size() : 8192; + return data.find('\0', 0) < check_len; + }; + if ((!old_missing && is_binary(old_path)) || + (!new_missing && is_binary(new_path))) { + return "Binary files differ\n"; + } + + std::string old_arg = old_missing ? "/dev/null" : std::string(old_path); + std::string new_arg = new_missing ? "/dev/null" : std::string(new_path); + + std::string old_label; + std::string new_label; + if (p_format == "ab") { + old_label = "a/" + std::string(file); + new_label = "b/" + std::string(file); + } else if (p_format == "0") { + old_label = std::string(file) + ".orig"; + new_label = std::string(file); + } else { + std::string work_base = basename(q.work_dir); + old_label = work_base + ".orig/" + std::string(file); + new_label = work_base + "/" + std::string(file); + } + + if (old_missing) { + old_label = "/dev/null"; + } + if (new_missing) { + new_label = "/dev/null"; + } + + // Append file timestamps unless suppressed + if (!no_timestamps) { + if (!old_missing) + old_label += format_file_timestamp(old_path); + if (!new_missing) + new_label += format_file_timestamp(new_path); + } + + if (reverse) { + std::swap(old_arg, new_arg); + } + + // Use built-in diff when no external diff utility is specified + if (diff_cmd_base.empty()) { + int ctx = context_lines; + // QUILT_DIFF_OPTS may override context lines + auto extra_diff_opts = shell_split(get_env("QUILT_DIFF_OPTS")); + int opts_ctx = parse_diff_opts_context(extra_diff_opts); + if (opts_ctx >= 0) ctx = opts_ctx; + + DiffResult result = builtin_diff(old_arg, new_arg, ctx, + old_label, new_label, diff_format, + diff_algorithm); + return result.output; + } + + // External diff utility path + std::vector cmd_argv(diff_cmd_base.begin(), diff_cmd_base.end()); + auto extra_diff_opts = shell_split(get_env("QUILT_DIFF_OPTS")); + for (const auto &opt : extra_diff_opts) { + cmd_argv.push_back(opt); + } + + cmd_argv.push_back("--label"); + cmd_argv.push_back(old_label); + cmd_argv.push_back("--label"); + cmd_argv.push_back(new_label); + cmd_argv.push_back(old_arg); + cmd_argv.push_back(new_arg); + + ProcessResult result = run_cmd(cmd_argv); + if (result.exit_code == 2) { + return {}; + } + + return result.out; +} + +static std::string generate_file_diff(const QuiltState &q, std::string_view patch, + std::string_view file, + std::string_view p_format = "1", + bool reverse = false, + std::span diff_cmd_base = {}, + int context_lines = 3, + DiffFormat diff_format = DiffFormat::unified, + bool no_timestamps = false, + DiffAlgorithm diff_algorithm = DiffAlgorithm::myers) { + std::string backup_path = path_join(pc_patch_dir(q, patch), file); + std::string working_path = path_join(q.work_dir, file); + return generate_path_diff(q, file, backup_path, true, working_path, false, + p_format, reverse, diff_cmd_base, + context_lines, diff_format, no_timestamps, + diff_algorithm); +} + +static std::map split_patch_by_file(std::string_view content) { + std::map sections; + auto lines = split_lines(content); + std::string current_file; + std::string current_section; + + auto flush = [&]() { + if (!current_file.empty() && !current_section.empty()) { + sections[current_file] = current_section; + } + current_file.clear(); + current_section.clear(); + }; + + for (const auto &line : lines) { + if (line.starts_with("Index:") || line.starts_with("diff ")) { + flush(); + current_section += line + "\n"; + } else if (line.starts_with("+++ ")) { + // Extract filename from +++ line + std::string_view rest = std::string_view(line).substr(4); + if (rest.starts_with("/dev/null")) { + // File deletion: current_file already set from --- line + } else { + // Strip b/ prefix and trailing tab/timestamp + if (rest.starts_with("b/")) rest = rest.substr(2); + auto tab = str_find(rest, '\t'); + if (tab >= 0) rest = rest.substr(0, checked_cast(tab)); + // Strip leading directory component (e.g., "dir.orig/") + auto slash = str_find(rest, '/'); + if (slash >= 0) { + current_file = trim(rest.substr(checked_cast(slash + 1))); + } else { + current_file = trim(rest); + } + } + current_section += line + "\n"; + } else if (line.starts_with("===")) { + current_section += line + "\n"; + } else if (line.starts_with("--- ")) { + // For file deletions (+++ /dev/null), we get the name from --- + if (current_file.empty()) { + std::string_view rest = std::string_view(line).substr(4); + if (!rest.starts_with("/dev/null")) { + if (rest.starts_with("a/")) rest = rest.substr(2); + auto tab = str_find(rest, '\t'); + if (tab >= 0) rest = rest.substr(0, checked_cast(tab)); + auto slash = str_find(rest, '/'); + if (slash >= 0) { + current_file = trim(rest.substr(checked_cast(slash + 1))); + } else { + current_file = trim(rest); + } + } + } + current_section += line + "\n"; + } else { + current_section += line + "\n"; + } + } + flush(); + return sections; +} + +static void append_unique_files(std::vector &dst, + std::set &seen, + std::span src) { + for (const auto &file : src) { + if (seen.insert(file).second) { + dst.push_back(file); + } + } +} + +static std::vector collect_files_for_patches( + const QuiltState &q, std::span patches) { + std::vector files; + std::set seen; + for (const auto &patch : patches) { + append_unique_files(files, seen, files_in_patch(q, patch)); + } + return files; +} + +static std::vector patch_range_for_diff(const QuiltState &q, + std::string_view last_patch) { + if (last_patch.empty()) { + return q.applied; + } + + std::vector patches; + for (const auto &patch : q.applied) { + patches.push_back(patch); + if (patch == last_patch) { + return patches; + } + } + + return {std::string(last_patch)}; +} + +static std::string first_patch_for_file(const QuiltState &q, + std::span patches, + std::string_view file) { + for (const auto &patch : patches) { + auto tracked = files_in_patch(q, patch); + if (std::ranges::find(tracked, file) != tracked.end()) { + return patch; + } + } + return ""; +} + + +static void apply_file_filter(std::vector &tracked, + std::span file_filter) { + if (file_filter.empty()) { + return; + } + + std::vector filtered; + for (const auto &tracked_file : tracked) { + for (const auto &wanted_file : file_filter) { + if (tracked_file == wanted_file) { + filtered.push_back(tracked_file); + break; + } + } + } + tracked = std::move(filtered); +} + +int cmd_snapshot(QuiltState &q, int argc, char **argv) { + bool remove_snapshot = false; + + for (int i = 1; i < argc; ++i) { + std::string_view arg = argv[i]; + if (arg == "-d") { + remove_snapshot = true; + continue; + } + err_line("Usage: quilt snapshot [-d]"); + return 1; + } + + std::string snap_dir = pc_patch_dir(q, SNAPSHOT_PATCH); + if (is_directory(snap_dir) && !delete_dir_recursive(snap_dir)) { + err_line("Failed to remove " + snap_dir); + return 1; + } + + if (remove_snapshot) { + return 0; + } + + if (!ensure_pc_dir(q)) { + return 1; + } + if (!make_dirs(snap_dir)) { + err_line("Failed to create " + snap_dir); + return 1; + } + + auto tracked = collect_files_for_patches(q, q.applied); + for (const auto &file : tracked) { + if (!backup_file(q, SNAPSHOT_PATCH, file)) { + err_line("Failed to snapshot " + file); + return 1; + } + } + + return 0; +} + +// Built-in diffstat: parse unified diff, produce a summary matching +// the output format of the external diffstat(1) utility. +static std::string generate_diffstat(std::string_view diff) +{ + struct FileStat { + std::string name; + ptrdiff_t added = 0; + ptrdiff_t removed = 0; + }; + + std::vector stats; + auto lines = split_lines(diff); + + for (ptrdiff_t i = 0; i < std::ssize(lines); ++i) { + const auto &line = lines[checked_cast(i)]; + + // Detect file header: "--- a/file" followed by "+++ b/file" + if (line.starts_with("--- ") && + i + 1 < std::ssize(lines) && + lines[checked_cast(i + 1)].starts_with("+++ ")) { + const auto &plus_line = lines[checked_cast(i + 1)]; + + // Extract filename from +++ line, strip "b/" prefix + auto name = plus_line.substr(4); + // Strip trailing timestamp (tab-separated) + auto tab = str_find(name, '\t'); + if (tab >= 0) name = name.substr(0, checked_cast(tab)); + // Strip one leading path component (a/ or b/ prefix) + auto slash = str_find(name, '/'); + if (slash >= 0) name = name.substr(checked_cast(slash + 1)); + // /dev/null means new or deleted file — use --- line instead + if (name == "dev/null" || plus_line.substr(4).starts_with("/dev/null")) { + name = lines[checked_cast(i)].substr(4); + tab = str_find(name, '\t'); + if (tab >= 0) name = name.substr(0, checked_cast(tab)); + slash = str_find(name, '/'); + if (slash >= 0) name = name.substr(checked_cast(slash + 1)); + } + + stats.push_back({std::string(name), 0, 0}); + i += 1; // skip +++ line + continue; + } + + if (stats.empty()) continue; + + if (line.starts_with("+") && !line.starts_with("+++")) + stats.back().added++; + else if (line.starts_with("-") && !line.starts_with("---")) + stats.back().removed++; + } + + if (stats.empty()) return {}; + + // Leading "---" separator (matches git format-patch / original quilt) + std::string result = "---\n"; + + // Find max filename width and max change count + ptrdiff_t max_name = 0; + ptrdiff_t max_changes = 0; + for (const auto &s : stats) { + max_name = std::max(max_name, std::ssize(s.name)); + max_changes = std::max(max_changes, s.added + s.removed); + } + + // Format change count to find its width (minimum 4, matching diffstat) + auto num_width = std::max(std::ssize(std::to_string(max_changes)), + static_cast(4)); + + // Bar graph width: fit in ~72 columns after " name | num " + // 1 (leading space) + max_name + 3 (" | ") + num_width + 1 (space) + ptrdiff_t used = 1 + max_name + 3 + num_width + 1; + ptrdiff_t bar_width = std::max(static_cast(1), + static_cast(72) - used); + + // Scale factor for bar graph + double scale = (max_changes > bar_width) + ? static_cast(bar_width) / static_cast(max_changes) + : 1.0; + + ptrdiff_t total_added = 0, total_removed = 0; + ptrdiff_t total_files = std::ssize(stats); + + for (const auto &s : stats) { + total_added += s.added; + total_removed += s.removed; + + ptrdiff_t changes = s.added + s.removed; + ptrdiff_t plus_bars = static_cast( + static_cast(s.added) * scale + 0.5); + ptrdiff_t minus_bars = static_cast( + static_cast(s.removed) * scale + 0.5); + + // Ensure at least 1 bar for non-zero counts + if (s.added > 0 && plus_bars == 0) plus_bars = 1; + if (s.removed > 0 && minus_bars == 0) minus_bars = 1; + + // Cap total bars at scaled width + ptrdiff_t total_bars = plus_bars + minus_bars; + ptrdiff_t limit = static_cast( + static_cast(changes) * scale + 0.5); + if (limit < 1 && changes > 0) limit = 1; + if (total_bars > limit) { + // Reduce the larger portion + if (plus_bars > minus_bars) + plus_bars = limit - minus_bars; + else + minus_bars = limit - plus_bars; + } + + result += ' '; + result += s.name; + for (ptrdiff_t j = std::ssize(s.name); j < max_name; ++j) + result += ' '; + result += " | "; + auto num_str = std::to_string(changes); + for (ptrdiff_t j = std::ssize(num_str); j < num_width; ++j) + result += ' '; + result += num_str; + result += ' '; + for (ptrdiff_t j = 0; j < plus_bars; ++j) result += '+'; + for (ptrdiff_t j = 0; j < minus_bars; ++j) result += '-'; + result += '\n'; + } + + // Summary line + result += ' '; + result += std::to_string(total_files); + result += (total_files == 1) ? " file changed" : " files changed"; + if (total_added > 0) { + result += ", "; + result += std::to_string(total_added); + result += (total_added == 1) ? " insertion(+)" : " insertions(+)"; + } + if (total_removed > 0) { + result += ", "; + result += std::to_string(total_removed); + result += (total_removed == 1) ? " deletion(-)" : " deletions(-)"; + } + result += '\n'; + + return result; +} + +// Remove an existing diffstat section from a patch header. +// Detects "---" separator followed by " file | N ++--" lines ending +// with a "N file(s) changed" summary line. +static std::string remove_diffstat_section(std::string_view header) { + auto lines = split_lines(header); + std::string result; + for (ptrdiff_t i = 0; i < std::ssize(lines); ++i) { + const auto &line = lines[checked_cast(i)]; + + // Detect "---" separator followed by diffstat, or bare diffstat + ptrdiff_t ds_start = i; + if (line == "---" && i + 1 < std::ssize(lines)) { + ds_start = i + 1; + } + + const auto &first = lines[checked_cast(ds_start)]; + if (!first.empty() && first[0] == ' ' && + str_find(first, '|') >= 0) { + // Look ahead to confirm this is a diffstat block + bool found_summary = false; + ptrdiff_t summary_end = -1; + for (ptrdiff_t j = ds_start; j < std::ssize(lines); ++j) { + const auto &l = lines[checked_cast(j)]; + if (l.find("changed") != std::string::npos && + l.find("file") != std::string::npos) { + found_summary = true; + summary_end = j; + break; + } + // If we hit an empty line or non-diffstat line, stop + if (l.empty() || (l[0] != ' ' && str_find(l, '|') < 0)) + break; + } + if (found_summary) { + // Skip the entire diffstat block including summary + i = summary_end; + // Also skip a trailing blank line after diffstat + if (i + 1 < std::ssize(lines) && lines[checked_cast(i + 1)].empty()) + i++; + continue; + } + } + result += line; + result += '\n'; + } + return result; +} + +// Strip trailing whitespace from diff output lines. +// Returns the cleaned diff and emits warnings to stderr. + +int cmd_refresh(QuiltState &q, int argc, char **argv) { + if (q.applied.empty()) { + err_line("No patches applied"); + return 1; + } + + // Parse options + std::string patch; + std::string p_format; + bool explicit_p = false; + int i = 1; + bool no_timestamps = !get_env("QUILT_NO_DIFF_TIMESTAMPS").empty(); + bool no_index = !get_env("QUILT_NO_DIFF_INDEX").empty(); + bool sort_files = true; + bool force = false; + std::string diff_type; + std::string context_num; + bool opt_fork = false; + std::string fork_name; + bool opt_diffstat = false; + bool opt_backup = false; + bool opt_strip_whitespace = false; + DiffAlgorithm diff_algorithm = DiffAlgorithm::myers; + { + auto env_algo = get_env("QUILT_DIFF_ALGORITHM"); + if (!env_algo.empty()) { + auto parsed = parse_diff_algorithm(env_algo); + if (!parsed) { + err("Unknown diff algorithm: "); err_line(env_algo); + return 1; + } + diff_algorithm = *parsed; + } + } + + while (i < argc) { + std::string_view arg = argv[i]; + if (arg == "-p" && i + 1 < argc) { + p_format = std::string(argv[i + 1]); + explicit_p = true; + i += 2; + continue; + } + if (arg.starts_with("-p") && std::ssize(arg) > 2) { + p_format = std::string(arg.substr(2)); + explicit_p = true; + i += 1; + continue; + } + if (arg == "-f") { + force = true; + i += 1; + continue; + } + if (arg == "-u") { + diff_type = "u"; + context_num.clear(); + i += 1; + continue; + } + if (arg.starts_with("-U")) { + diff_type = "U"; + if (arg == "-U" && i + 1 < argc) { + context_num = argv[i + 1]; + i += 2; + } else { + context_num = std::string(arg.substr(2)); + i += 1; + } + continue; + } + if (arg == "-c") { + diff_type = "c"; + context_num.clear(); + i += 1; + continue; + } + if (arg.starts_with("-C")) { + diff_type = "C"; + if (arg == "-C" && i + 1 < argc) { + context_num = argv[i + 1]; + i += 2; + } else { + context_num = std::string(arg.substr(2)); + i += 1; + } + continue; + } + if (arg.starts_with("-z")) { + opt_fork = true; + if (std::ssize(arg) > 2) { + fork_name = strip_patches_prefix(q, arg.substr(2)); + } + i += 1; + continue; + } + if (arg == "--no-timestamps" || arg == "--no-timestamp") { + no_timestamps = true; + i += 1; + continue; + } + if (arg == "--no-index") { + no_index = true; + i += 1; + continue; + } + if (arg == "--sort") { + sort_files = true; + i += 1; + continue; + } + if (arg == "--diffstat") { + opt_diffstat = true; + i += 1; + continue; + } + if (arg == "--backup") { + opt_backup = true; + i += 1; + continue; + } + if (arg == "--strip-trailing-whitespace") { + opt_strip_whitespace = true; + i += 1; + continue; + } + if (arg.starts_with("--diff-algorithm=")) { + auto name = arg.substr(17); + auto algo = parse_diff_algorithm(name); + if (!algo) { + err("Unknown diff algorithm: "); err_line(name); + return 1; + } + diff_algorithm = *algo; + i += 1; + continue; + } + if (arg == "--diff-algorithm" && i + 1 < argc) { + std::string_view name = argv[i + 1]; + auto algo = parse_diff_algorithm(name); + if (!algo) { + err("Unknown diff algorithm: "); err_line(name); + return 1; + } + diff_algorithm = *algo; + i += 2; + continue; + } + if (arg[0] == '-') { + err("Unrecognized option: "); err_line(arg); + return 1; + } + // Non-option: patch name + if (patch.empty()) { + patch = arg; + } + i += 1; + } + + if (patch.empty()) { + patch = q.applied.back(); + } + + if (!explicit_p) { + p_format = q.get_p_format(patch); + } + + // Compute diff format and context lines + DiffFormat diff_format = DiffFormat::unified; + int ctx_lines = 3; + if (diff_type == "c") { + diff_format = DiffFormat::context; + } else if (diff_type == "C") { + diff_format = DiffFormat::context; + ctx_lines = checked_cast(parse_int(context_num)); + } else if (diff_type == "U") { + ctx_lines = checked_cast(parse_int(context_num)); + } + + // Fork before refresh if -z was given + // Unlike standalone "fork" (which replaces the original), refresh -z + // inserts a new patch *after* the original and refreshes the fork. + // The original patch keeps its content. The fork captures only the + // delta between the original's refreshed state and the current working + // tree. + bool did_fork = false; + if (opt_fork) { + if (patch != q.applied.back()) { + err_line("Can only use -z with the topmost applied patch"); + return 1; + } + std::string old_name(patch); + + // Generate fork name + std::string new_name; + if (!fork_name.empty()) { + new_name = fork_name; + } else { + auto dot = str_rfind(old_name, '.'); + if (dot > 0) { + new_name = old_name.substr(0, checked_cast(dot)) + "-2" + old_name.substr(checked_cast(dot)); + } else { + new_name = old_name + "-2"; + } + } + + if (q.find_in_series(new_name)) { + err("Patch "); err(new_name); err_line(" already exists in series"); + return 1; + } + + auto idx = q.find_in_series(old_name); + if (!idx) { + err("Patch "); err(old_name); err_line(" is not in series"); + return 1; + } + + // Create the fork's .pc/ directory with backups representing the + // file state *after* the original patch (i.e., the intermediate + // state between the two patches). Reconstruct this by applying + // the original patch to the backup copies in an in-memory FS. + std::string old_pc = pc_patch_dir(q, old_name); + std::string new_pc = pc_patch_dir(q, new_name); + make_dirs(new_pc); + + std::string orig_patch_path = path_join(q.work_dir, q.patches_dir, old_name); + std::string orig_patch_content = read_file(orig_patch_path); + int orig_strip = q.get_strip_level(old_name); + auto orig_files = files_in_patch(q, old_name); + + // Build in-memory FS from backup copies, apply original patch + std::map memfs; + for (const auto &file : orig_files) { + std::string backup_path = path_join(old_pc, file); + if (file_exists(backup_path) && !is_placeholder_copy(backup_path)) { + memfs[file] = read_file(backup_path); + } + } + if (!orig_patch_content.empty()) { + PatchOptions popts; + popts.strip_level = orig_strip; + popts.quiet = true; + popts.fs = &memfs; + if (q.patch_reversed.contains(old_name)) popts.reverse = true; + builtin_patch(orig_patch_content, popts); + } + + // Write the intermediate states to the fork's .pc/ directory + for (const auto &file : orig_files) { + std::string fork_backup = path_join(new_pc, file); + std::string fork_dir = dirname(fork_backup); + if (!is_directory(fork_dir)) make_dirs(fork_dir); + + auto it = memfs.find(file); + if (it != memfs.end()) { + write_file(fork_backup, it->second); + } else { + // File was deleted by patch or not present — write empty placeholder + write_file(fork_backup, ""); + } + } + + // Write .timestamp for the fork + write_file(path_join(new_pc, ".timestamp"), ""); + + // Migrate per-patch metadata to fork + auto sl_it = q.patch_strip_level.find(old_name); + if (sl_it != q.patch_strip_level.end()) { + q.patch_strip_level[new_name] = sl_it->second; + } + if (q.patch_reversed.contains(old_name)) { + q.patch_reversed.insert(new_name); + } + + // Insert new patch after original in series + q.series.insert(q.series.begin() + *idx + 1, new_name); + std::string series_abs = path_join(q.work_dir, q.series_file); + if (!write_series(series_abs, q.series, q.patch_strip_level, q.patch_reversed)) { + err_line("Failed to write series file."); + return 1; + } + + // Update applied: add fork after original + q.applied.push_back(new_name); + std::string applied_path = path_join(q.work_dir, q.pc_dir, "applied-patches"); + write_applied(applied_path, q.applied); + + out_line("Fork of patch " + patch_path_display(q, old_name) + + " created as " + patch_path_display(q, new_name)); + patch = new_name; + // Suppress the "Refreshed patch" message; the fork message is sufficient + did_fork = true; + } + + // Compute shadowed files (files modified by patches above this one) + // For each shadowed file, record the first patch above that tracks it + // (needed to find its backup as the "new" side of the diff). + std::set shadowed; + std::map shadow_next_patch; + if (patch != q.applied.back()) { + bool above = false; + for (const auto &a : q.applied) { + if (above) { + auto above_files = files_in_patch(q, a); + for (const auto &f : above_files) { + if (!shadowed.contains(f)) { + shadow_next_patch[f] = a; + } + shadowed.insert(f); + } + } + if (a == patch) above = true; + } + } + + if (!shadowed.empty() && !force) { + err("More recent patches modify files in patch "); + err(patch_path_display(q, patch)); err_line(". Enforce refresh with -f."); + return 1; + } + + // Get files tracked by this patch + auto tracked = files_in_patch(q, patch); + if (sort_files) { + std::ranges::sort(tracked); + } + + // Read existing patch file for header + std::string patch_file = path_join(q.work_dir, q.patches_dir, patch); + std::string old_content; + std::string header; + if (file_exists(patch_file)) { + old_content = read_file(patch_file); + header = read_patch_header(patch_file); + } + + // Backup old patch file if requested + if (opt_backup && file_exists(patch_file)) { + copy_file(patch_file, patch_file + "~"); + } + + // Generate diffs + std::string work_base = basename(q.work_dir); + std::string patch_content = header; + + for (const auto &file : tracked) { + if (shadowed.contains(file)) { + // Diff this patch's backup against the next patch's backup + auto it = shadow_next_patch.find(file); + if (it != shadow_next_patch.end()) { + std::string this_backup = path_join(pc_patch_dir(q, patch), file); + std::string next_backup = path_join(pc_patch_dir(q, it->second), file); + std::string diff_out = generate_path_diff(q, file, + this_backup, true, next_backup, true, + p_format, false, {}, ctx_lines, diff_format, no_timestamps, + diff_algorithm); + if (!diff_out.empty()) { + if (!no_index) { + std::string idx_name; + if (p_format == "0") idx_name = file; + else if (p_format == "ab") idx_name = "b/" + file; + else idx_name = basename(q.work_dir) + "/" + file; + patch_content += "Index: " + idx_name + "\n"; + patch_content += "===================================================================\n"; + } + patch_content += diff_out; + if (!patch_content.empty() && patch_content.back() != '\n') { + patch_content += '\n'; + } + } + } + continue; + } + // Strip trailing whitespace from lines modified by this patch + if (opt_strip_whitespace) { + std::string working_path = path_join(q.work_dir, file); + if (file_exists(working_path)) { + // Find which lines are modified by diffing backup vs working + std::set modified_lines; + std::string diff_check = generate_file_diff( + q, patch, file, "1", false, {}, 0, + DiffFormat::unified, true, diff_algorithm); + if (!diff_check.empty()) { + auto dlines = split_lines(diff_check); + int new_lineno = 0; + for (const auto &dl : dlines) { + if (dl.starts_with("---") || dl.starts_with("+++")) { + continue; + } + if (dl.starts_with("@@")) { + // Parse @@ -X,Y +N,M @@ + auto plus = str_find(dl, '+'); + if (plus >= 0) { + auto comma = str_find( + std::string_view(dl).substr( + checked_cast(plus)), ','); + std::string_view num_str; + if (comma >= 0) { + num_str = std::string_view(dl).substr( + checked_cast(plus) + 1, + checked_cast(comma) - 1); + } else { + auto sp = str_find( + std::string_view(dl).substr( + checked_cast(plus)), ' '); + if (sp >= 0) { + num_str = std::string_view(dl).substr( + checked_cast(plus) + 1, + checked_cast(sp) - 1); + } + } + if (!num_str.empty()) { + int n = 0; + std::from_chars(num_str.data(), + num_str.data() + num_str.size(), n); + new_lineno = n - 1; // will be incremented + } + } + } else if (dl.starts_with("+")) { + new_lineno++; + modified_lines.insert(new_lineno); + } else if (!dl.starts_with("-")) { + new_lineno++; // context line + } + } + } + + std::string content = read_file(working_path); + std::string stripped; + auto lines = split_lines(content); + int lineno = 0; + for (const auto &line : lines) { + lineno++; + std::string_view l = line; + bool is_modified = modified_lines.contains(lineno); + auto end = l.find_last_not_of(" \t"); + if (is_modified && end == std::string::npos) { + if (!l.empty()) { + out_line("Removing trailing whitespace from line " + + std::to_string(lineno) + " of " + file); + } + stripped += '\n'; + } else if (is_modified && + static_cast(end) + 1 < std::ssize(l)) { + out_line("Removing trailing whitespace from line " + + std::to_string(lineno) + " of " + file); + stripped += l.substr(0, end + 1); + stripped += '\n'; + } else { + stripped += l; + stripped += '\n'; + } + } + if (stripped != content) { + write_file(working_path, stripped); + } + } + } + + std::string diff_out = generate_file_diff(q, patch, file, p_format, + false, {}, ctx_lines, + diff_format, no_timestamps, + diff_algorithm); + if (diff_out.starts_with("Binary files ")) { + err("Diff failed on file '"); err(file); err_line("', aborting"); + return 1; + } + if (!diff_out.empty()) { + if (!no_index) { + std::string idx_name; + if (p_format == "0") idx_name = file; + else if (p_format == "ab") idx_name = "b/" + file; + else idx_name = work_base + "/" + file; + patch_content += "Index: " + idx_name + "\n"; + patch_content += "===================================================================\n"; + } + patch_content += diff_out; + // Ensure trailing newline + if (!patch_content.empty() && patch_content.back() != '\n') { + patch_content += '\n'; + } + } + } + + // Add diffstat to header if requested + if (opt_diffstat) { + std::string diff_portion = patch_content.substr(checked_cast(std::ssize(header))); + if (!diff_portion.empty()) { + std::string ds_out = generate_diffstat(diff_portion); + if (!ds_out.empty()) { + std::string clean_header = remove_diffstat_section(header); + // Remove trailing blank lines from header + while (std::ssize(clean_header) > 1 && + clean_header[checked_cast(std::ssize(clean_header) - 1)] == '\n' && + clean_header[checked_cast(std::ssize(clean_header) - 2)] == '\n') { + clean_header.pop_back(); + } + patch_content = clean_header; + if (!patch_content.empty() && patch_content.back() != '\n') + patch_content += '\n'; + patch_content += ds_out; + if (!ds_out.empty() && ds_out.back() != '\n') + patch_content += '\n'; + patch_content += '\n'; + patch_content += diff_portion; + } + } + } + + // Check if patch has no diff hunks + bool has_diff = false; + for (auto &line : split_lines(patch_content)) { + if (line.starts_with("--- ") || line.starts_with("diff ")) { + has_diff = true; + break; + } + } + + // Check if patch content is unchanged (only skip write if file exists) + if (patch_content == old_content && file_exists(patch_file)) { + if (!has_diff) { + out("Nothing in patch "); out_line(patch_path_display(q, patch)); + } else { + out("Patch "); out(patch_path_display(q, patch)); + out_line(" is unchanged"); + } + return 0; + } + + // Ensure patches directory exists + std::string patch_dir = dirname(patch_file); + if (!is_directory(patch_dir)) { + make_dirs(patch_dir); + } + + // Write the patch file + if (!write_file(patch_file, patch_content)) { + err_line("Failed to write patch file " + patch_file); + return 1; + } + + // Update .timestamp + write_file(path_join(pc_patch_dir(q, patch), ".timestamp"), ""); + + // Clear .needs_refresh marker if present + std::string nr = path_join(pc_patch_dir(q, patch), ".needs_refresh"); + if (file_exists(nr)) { + delete_file(nr); + } + + if (!did_fork) { + if (!has_diff) { + out("Nothing in patch "); out_line(patch_path_display(q, patch)); + } else { + out("Refreshed patch "); out_line(patch_path_display(q, patch)); + } + } + return 0; +} + +int cmd_diff(QuiltState &q, int argc, char **argv) { + if (q.applied.empty()) { + err_line("No patches applied"); + return 1; + } + + // Parse options + std::string_view patch; + std::string p_format; + bool explicit_p = false; + std::vector file_filter; + bool no_timestamps = !get_env("QUILT_NO_DIFF_TIMESTAMPS").empty(); + bool no_index = !get_env("QUILT_NO_DIFF_INDEX").empty(); + bool since_refresh = false; + bool against_snapshot = false; + bool reverse = false; + bool sort_files = true; + std::string diff_utility; + std::string combine_patch; + std::string diff_type = "u"; + std::string context_num; + DiffAlgorithm diff_algorithm = DiffAlgorithm::myers; + { + auto env_algo = get_env("QUILT_DIFF_ALGORITHM"); + if (!env_algo.empty()) { + auto parsed = parse_diff_algorithm(env_algo); + if (!parsed) { + err("Unknown diff algorithm: "); err_line(env_algo); + return 1; + } + diff_algorithm = *parsed; + } + } + int i = 1; + + while (i < argc) { + std::string_view arg = argv[i]; + if (arg == "-P" && i + 1 < argc) { + patch = strip_patches_prefix(q, argv[i + 1]); + i += 2; + continue; + } + if (arg == "-p" && i + 1 < argc) { + p_format = std::string(argv[i + 1]); + explicit_p = true; + i += 2; + continue; + } + if (arg.starts_with("-p") && std::ssize(arg) > 2) { + p_format = std::string(arg.substr(2)); + explicit_p = true; + i += 1; + continue; + } + if (arg == "-u") { + diff_type = "u"; + context_num.clear(); + i += 1; + continue; + } + if (arg == "-c") { + diff_type = "c"; + context_num.clear(); + i += 1; + continue; + } + if (arg.starts_with("-C")) { + diff_type = "C"; + if (arg == "-C" && i + 1 < argc) { + context_num = argv[i + 1]; + i += 2; + } else { + context_num = std::string(arg.substr(2)); + i += 1; + } + continue; + } + if (arg.starts_with("-U")) { + diff_type = "U"; + if (arg == "-U" && i + 1 < argc) { + context_num = argv[i + 1]; + i += 2; + } else { + context_num = std::string(arg.substr(2)); + i += 1; + } + continue; + } + if (arg == "-z") { + since_refresh = true; + i += 1; + continue; + } + if (arg == "--snapshot") { + against_snapshot = true; + i += 1; + continue; + } + if (arg == "-R") { + reverse = true; + i += 1; + continue; + } + if (arg == "--no-timestamps" || arg == "--no-timestamp") { + no_timestamps = true; + i += 1; + continue; + } + if (arg == "--no-index") { + no_index = true; + i += 1; + continue; + } + if (arg == "--sort") { + sort_files = true; + i += 1; + continue; + } + if (arg == "--combine" && i + 1 < argc) { + combine_patch = argv[i + 1]; + i += 2; + continue; + } + if (arg.starts_with("--combine=")) { + combine_patch = std::string(arg.substr(10)); + i += 1; + continue; + } + if (arg.starts_with("--diff=")) { + diff_utility = std::string(arg.substr(7)); + i += 1; + continue; + } + if (arg.starts_with("--diff-algorithm=")) { + auto name = arg.substr(17); + auto algo = parse_diff_algorithm(name); + if (!algo) { + err("Unknown diff algorithm: "); err_line(name); + return 1; + } + diff_algorithm = *algo; + i += 1; + continue; + } + if (arg == "--diff-algorithm" && i + 1 < argc) { + std::string_view name = argv[i + 1]; + auto algo = parse_diff_algorithm(name); + if (!algo) { + err("Unknown diff algorithm: "); err_line(name); + return 1; + } + diff_algorithm = *algo; + i += 2; + continue; + } + if (arg == "--color" || arg.starts_with("--color=")) { + if (arg.starts_with("--color=")) { + auto val = arg.substr(8); + if (val != "always" && val != "auto" && val != "never") { + err("Invalid --color value: "); err_line(val); + return 1; + } + } + i += 1; + continue; + } + if (arg[0] == '-') { + err("Unrecognized option: "); err_line(arg); + return 1; + } + // Non-option: file name or patch name + if (arg[0] != '-') { + file_filter.push_back(subdir_path(q, arg)); + } + i += 1; + } + + if (patch.empty()) { + patch = q.applied.back(); + } + + if (!explicit_p) { + p_format = q.get_p_format(patch); + } + + if (since_refresh && against_snapshot) { + err_line("Options `--snapshot' and `-z' cannot be combined."); + return 1; + } + + if (!combine_patch.empty() && since_refresh) { + err_line("Options `--combine' and `-z' cannot be combined."); + return 1; + } + + if (!combine_patch.empty() && against_snapshot) { + err_line("Options `--combine' and `--snapshot' cannot be combined."); + return 1; + } + + // Determine diff format and context lines for builtin diff + DiffFormat diff_format = DiffFormat::unified; + int ctx_lines = 3; + if (diff_type == "c") { + diff_format = DiffFormat::context; + } else if (diff_type == "C") { + diff_format = DiffFormat::context; + ctx_lines = checked_cast(parse_int(context_num)); + } else if (diff_type == "U") { + ctx_lines = checked_cast(parse_int(context_num)); + } + + // Build diff command base for external diff utility (empty = use builtin) + bool convert_to_context = false; + std::vector diff_cmd_base; + if (!diff_utility.empty()) { + auto parts = split_on_whitespace(diff_utility); + for (auto &p : parts) diff_cmd_base.push_back(std::move(p)); + // External diff: always request unified, convert to context in-process + convert_to_context = (diff_type == "c" || diff_type == "C"); + if (diff_type == "U" || diff_type == "C") { + diff_cmd_base.push_back("-U"); + diff_cmd_base.push_back(context_num); + } else { + diff_cmd_base.push_back("-u"); + } + } + + auto emit_diff = [&](std::string_view d) { + if (convert_to_context) + out(unified_to_context(d)); + else + out(d); + }; + + // Resolve --combine patch name + std::string combine_start; + if (!combine_patch.empty()) { + if (combine_patch == "-") { + combine_start = q.applied.front(); + } else { + combine_start = strip_patches_prefix(q, combine_patch); + } + } + + auto patches = patch_range_for_diff(q, patch); + std::vector tracked; + if (against_snapshot) { + std::string snap_dir = pc_patch_dir(q, SNAPSHOT_PATCH); + if (!is_directory(snap_dir)) { + err_line("No snapshot to diff against"); + return 1; + } + + std::set seen; + append_unique_files(tracked, seen, files_in_patch(q, SNAPSHOT_PATCH)); + append_unique_files(tracked, seen, collect_files_for_patches(q, patches)); + } else if (!combine_start.empty()) { + // Collect files across the combine range + std::vector combine_range; + bool in_range = false; + for (const auto &a : q.applied) { + if (a == combine_start) in_range = true; + if (in_range) combine_range.push_back(a); + if (a == patch) break; + } + tracked = collect_files_for_patches(q, combine_range); + } else { + tracked = files_in_patch(q, patch); + } + + apply_file_filter(tracked, file_filter); + + if (sort_files) { + std::ranges::sort(tracked); + } + + std::string work_base = basename(q.work_dir); + + if (since_refresh) { + // diff -z: show changes since last refresh. + // For each tracked file, reconstruct the "refreshed state" by applying + // the stored patch to the backup, then diff that against the working file. + std::string patch_file = path_join(q.work_dir, q.patches_dir, patch); + std::string stored_content; + std::map stored_sections; + if (file_exists(patch_file)) { + stored_content = read_file(patch_file); + stored_sections = split_patch_by_file(stored_content); + } + + // Create temp directory for reconstructing refreshed state + std::string tmp_dir = make_temp_dir(); + if (tmp_dir.empty()) { + err_line("Failed to create temp directory"); + return 1; + } + + for (const auto &file : tracked) { + std::string backup_path = path_join(pc_patch_dir(q, patch), file); + std::string working_path = path_join(q.work_dir, file); + std::string tmp_file = path_join(tmp_dir, file); + + // Ensure temp subdirectory exists + std::string tmp_file_dir = dirname(tmp_file); + if (!is_directory(tmp_file_dir)) { + make_dirs(tmp_file_dir); + } + + // Copy backup to temp (empty backup = file didn't exist) + if (file_exists(backup_path)) { + std::string backup_content = read_file(backup_path); + write_file(tmp_file, backup_content); + } else { + write_file(tmp_file, ""); + } + + // Apply stored patch section to temp file + auto it = stored_sections.find(file); + if (it != stored_sections.end() && !it->second.empty()) { + // Build a minimal patch with correct paths for -p0 + std::string mini_patch = "--- " + file + "\n+++ " + file + "\n"; + // Extract just the hunk lines from the stored section + auto section_lines = split_lines(it->second); + bool in_hunk = false; + for (const auto &sl : section_lines) { + if (sl.starts_with("@@")) { + in_hunk = true; + mini_patch += sl + "\n"; + } else if (in_hunk) { + mini_patch += sl + "\n"; + } + } + if (in_hunk) { + std::string saved_cwd = get_cwd(); + if (set_cwd(tmp_dir)) { + PatchOptions po; + po.strip_level = 0; + po.remove_empty = true; + po.quiet = true; + builtin_patch(mini_patch, po); + set_cwd(saved_cwd); + } + } + } + + // Now diff the reconstructed "refreshed" file against working file + if (!file_exists(working_path) && !file_exists(tmp_file)) continue; + + std::string old_label, new_label; + if (p_format == "ab") { + old_label = "a/" + file; + new_label = "b/" + file; + } else if (p_format == "0") { + old_label = file; + new_label = file; + } else { + old_label = work_base + ".orig/" + file; + new_label = work_base + "/" + file; + } + + std::string old_f = tmp_file; + std::string new_f = working_path; + if (!file_exists(working_path)) new_f = "/dev/null"; + + if (reverse) { + std::swap(old_f, new_f); + } + + std::string diff_out; + if (diff_cmd_base.empty()) { + // Use built-in diff + int ctx = ctx_lines; + auto extra_diff_opts = shell_split(get_env("QUILT_DIFF_OPTS")); + int opts_ctx = parse_diff_opts_context(extra_diff_opts); + if (opts_ctx >= 0) ctx = opts_ctx; + + DiffResult dr = builtin_diff(old_f, new_f, ctx, + old_label, new_label, diff_format, + diff_algorithm); + diff_out = std::move(dr.output); + } else { + std::vector diff_cmd = diff_cmd_base; + auto extra_diff_opts = shell_split(get_env("QUILT_DIFF_OPTS")); + for (const auto &opt : extra_diff_opts) diff_cmd.push_back(opt); + diff_cmd.push_back("--label"); + diff_cmd.push_back(old_label); + diff_cmd.push_back("--label"); + diff_cmd.push_back(new_label); + diff_cmd.push_back(old_f); + diff_cmd.push_back(new_f); + + ProcessResult result = run_cmd(diff_cmd); + if (result.exit_code == 1) { + diff_out = std::move(result.out); + } + } + if (!diff_out.empty()) { + if (!no_index) { + out("Index: " + (p_format == "0" ? file : p_format == "ab" ? "b/" + file : work_base + "/" + file) + "\n"); + out("===================================================================\n"); + } + emit_diff(diff_out); + } + } + + delete_dir_recursive(tmp_dir); + } else if (against_snapshot) { + for (const auto &file : tracked) { + std::string old_path = path_join(pc_patch_dir(q, SNAPSHOT_PATCH), file); + bool old_placeholder = true; + if (!file_exists(old_path)) { + std::string first_patch = first_patch_for_file(q, patches, file); + if (first_patch.empty()) { + continue; + } + old_path = path_join(pc_patch_dir(q, first_patch), file); + } + + std::string new_path = path_join(q.work_dir, file); + bool new_placeholder = false; + std::string shadowing_patch = next_patch_for_file(q, patch, file); + if (!shadowing_patch.empty()) { + new_path = path_join(pc_patch_dir(q, shadowing_patch), file); + new_placeholder = true; + } + + std::string diff_out = generate_path_diff( + q, file, old_path, old_placeholder, new_path, new_placeholder, + p_format, reverse, diff_cmd_base, ctx_lines, diff_format, + no_timestamps, diff_algorithm); + if (!diff_out.empty()) { + if (!no_index) { + out("Index: " + (p_format == "0" ? file : p_format == "ab" ? "b/" + file : work_base + "/" + file) + "\n"); + out("===================================================================\n"); + } + emit_diff(diff_out); + } + } + } else if (!combine_start.empty()) { + // --combine: diff backup from the earliest patch in range against working file + for (const auto &file : tracked) { + // Find the earliest patch in the combine range that tracks this file + std::string earliest; + bool in_range = false; + for (const auto &a : q.applied) { + if (a == combine_start) in_range = true; + if (in_range) { + auto fip = files_in_patch(q, a); + if (std::ranges::find(fip, file) != fip.end()) { + earliest = a; + break; + } + } + if (a == patch) break; + } + if (earliest.empty()) continue; + + std::string old_path = path_join(pc_patch_dir(q, earliest), file); + std::string new_path = path_join(q.work_dir, file); + bool new_placeholder = false; + + // If a patch above the range shadows this file, use its backup + std::string shadowing_patch = next_patch_for_file(q, patch, file); + if (!shadowing_patch.empty()) { + new_path = path_join(pc_patch_dir(q, shadowing_patch), file); + new_placeholder = true; + } + + std::string diff_out = generate_path_diff( + q, file, old_path, true, new_path, new_placeholder, + p_format, reverse, diff_cmd_base, ctx_lines, diff_format, + no_timestamps, diff_algorithm); + if (!diff_out.empty()) { + if (!no_index) { + out("Index: " + (p_format == "0" ? file : p_format == "ab" ? "b/" + file : work_base + "/" + file) + "\n"); + out("===================================================================\n"); + } + emit_diff(diff_out); + } + } + } else { + // Warn if more recent patches modify files in this patch + bool warned_shadowing = false; + for (const auto &file : tracked) { + std::string shadowing = next_patch_for_file(q, patch, file); + if (!shadowing.empty() && !warned_shadowing) { + err("Warning: more recent patches modify files in patch "); + err_line(patch_path_display(q, patch)); + warned_shadowing = true; + } + + std::string old_path = path_join(pc_patch_dir(q, patch), file); + std::string new_path = path_join(q.work_dir, file); + bool new_placeholder = false; + + // If a patch above this one shadows the file, use its backup + if (!shadowing.empty()) { + new_path = path_join(pc_patch_dir(q, shadowing), file); + new_placeholder = true; + } + + std::string diff_out = generate_path_diff( + q, file, old_path, true, new_path, new_placeholder, + p_format, reverse, diff_cmd_base, ctx_lines, diff_format, + no_timestamps, diff_algorithm); + if (!diff_out.empty()) { + if (!no_index) { + out("Index: " + (p_format == "0" ? file : p_format == "ab" ? "b/" + file : work_base + "/" + file) + "\n"); + out("===================================================================\n"); + } + emit_diff(diff_out); + } + } + } + + return 0; +} + +int cmd_revert(QuiltState &q, int argc, char **argv) { + if (q.applied.empty()) { + err_line("No patches applied"); + return 1; + } + + // Parse options + std::string_view patch = q.applied.back(); + std::vector files; + int i = 1; + while (i < argc) { + std::string_view arg = argv[i]; + if (arg == "-P" && i + 1 < argc) { + patch = strip_patches_prefix(q, argv[i + 1]); + i += 2; + continue; + } + if (arg[0] != '-') { + files.push_back(subdir_path(q, arg)); + } else { + err("Unrecognized option: "); err_line(arg); + return 1; + } + i += 1; + } + + if (files.empty()) { + err_line("Usage: quilt revert [-P patch] file ..."); + return 1; + } + + if (!q.is_applied(patch)) { + err("Patch "); err(format_patch(q, patch)); err_line(" is not applied"); + return 1; + } + + // Check if any later applied patch also modifies these files + bool found_patch = false; + for (const auto &ap : q.applied) { + if (!found_patch) { + if (ap == patch) found_patch = true; + continue; + } + // ap is a patch applied after 'patch' + auto later_files = files_in_patch(q, ap); + for (const auto &file : files) { + for (const auto &lf : later_files) { + if (lf == file) { + err("File "); err(file); + err(" modified by patch "); + err_line(patch_path_display(q, ap)); + return 1; + } + } + } + } + + // Read the patch file to apply its hunks to backup content + std::string patch_file = path_join(q.work_dir, q.patches_dir, patch); + std::string patch_text = read_file(patch_file); + int strip_level = q.patch_strip_level.count(std::string(patch)) + ? q.patch_strip_level.at(std::string(patch)) : 1; + + for (const auto &file : files) { + // Check if file is tracked by the patch + std::string backup_path = path_join(pc_patch_dir(q, patch), file); + if (!file_exists(backup_path)) { + err("File "); err(file); err(" is not in patch "); + err_line(patch_path_display(q, patch)); + return 1; + } + + // Build the clean post-patch state by applying patch to backup + std::string backup_content = read_file(backup_path); + std::map memfs; + memfs[file] = backup_content; + PatchOptions opts; + opts.strip_level = strip_level; + opts.quiet = true; + opts.fs = &memfs; + builtin_patch(patch_text, opts); + + std::string clean_content = memfs.count(file) ? memfs[file] : ""; + + // Check if current file matches clean state (unchanged) + std::string target = path_join(q.work_dir, file); + std::string current = file_exists(target) ? read_file(target) : ""; + if (current == clean_content) { + out("File "); out(file); + out_line(" is unchanged"); + continue; + } + + // Write the clean post-patch state + if (clean_content.empty()) { + // Post-patch state is empty — either file was deleted by patch + // or didn't exist. Remove the working-tree copy. + delete_file(target); + out("Changes to "); out(file); out(" in patch "); + out(patch_path_display(q, patch)); out_line(" reverted"); + continue; + } + + std::string target_dir = dirname(target); + if (!is_directory(target_dir)) { + make_dirs(target_dir); + } + if (!write_file(target, clean_content)) { + err("Failed to restore "); err_line(file); + return 1; + } + + out("Changes to "); out(file); out(" in patch "); + out(patch_path_display(q, patch)); out_line(" reverted"); + } + + return 0; +} + +// === src/cmd_manage.cpp === + +// This is free and unencumbered software released into the public domain. + + +static bool write_series_checked(const QuiltState &q, + std::span series) { + std::string series_abs = path_join(q.work_dir, q.series_file); + if (!write_series(series_abs, series, q.patch_strip_level, q.patch_reversed)) { + err_line("Failed to write series file."); + return false; + } + return true; +} + +static bool write_applied_checked(const QuiltState &q, + std::span applied) { + std::string applied_path = path_join(q.work_dir, q.pc_dir, "applied-patches"); + if (!write_applied(applied_path, applied)) { + err_line("Failed to write applied-patches."); + return false; + } + return true; +} + + +static std::string extract_header(std::string_view content) { + std::string header; + auto lines = split_lines(content); + for (const auto &line : lines) { + if (line.starts_with("Index:") || + line.starts_with("--- ") || + line.starts_with("diff ") || + line.starts_with("===")) { + break; + } + header += line; + header += '\n'; + } + return header; +} + +static std::string replace_header(std::string_view content, std::string_view new_header) { + std::string result; + auto lines = split_lines(content); + bool in_diff = false; + // Find where diffs start + ptrdiff_t diff_start = 0; + for (ptrdiff_t i = 0; i < std::ssize(lines); ++i) { + if (lines[checked_cast(i)].starts_with("Index:") || + lines[checked_cast(i)].starts_with("--- ") || + lines[checked_cast(i)].starts_with("diff ") || + lines[checked_cast(i)].starts_with("===")) { + diff_start = i; + in_diff = true; + break; + } + } + + result += std::string(new_header); + // Ensure header ends with newline if non-empty + if (!result.empty() && result.back() != '\n') { + result += '\n'; + } + + if (in_diff) { + for (ptrdiff_t i = diff_start; i < std::ssize(lines); ++i) { + result += lines[checked_cast(i)]; + result += '\n'; + } + } + return result; +} + + +int cmd_delete(QuiltState &q, int argc, char **argv) { + bool opt_remove = false; + bool opt_backup = false; + bool opt_next = false; + std::string_view patch_arg; + + for (int i = 1; i < argc; ++i) { + std::string_view arg = argv[i]; + if (arg == "-r") { + opt_remove = true; + } else if (arg == "--backup") { + opt_backup = true; + } else if (arg == "-n") { + opt_next = true; + } else if (arg[0] == '-') { + err("Unrecognized option: "); err_line(arg); + return 1; + } else { + patch_arg = strip_patches_prefix(q, arg); + } + } + + std::string patch; + if (!patch_arg.empty()) { + patch = patch_arg; + } else if (opt_next) { + // Next unapplied patch + ptrdiff_t top_idx = q.top_index(); + ptrdiff_t next_idx = top_idx + 1; + if (next_idx >= std::ssize(q.series)) { + err_line("No next patch"); + return 1; + } + patch = q.series[checked_cast(next_idx)]; + } else { + // Topmost applied patch + if (q.applied.empty()) { + err_line("No patches applied"); + return 1; + } + patch = q.applied.back(); + } + + // Verify patch is in series + auto idx = q.find_in_series(patch); + if (!idx) { + err("Patch "); err(patch); err_line(" is not in series"); + return 1; + } + + // If patch is applied, only allow deleting the topmost patch + if (q.is_applied(patch)) { + if (patch != q.applied.back()) { + err("Patch "); err(patch_path_display(q, patch)); + err_line(" is currently applied"); + return 1; + } + // Pop the topmost patch silently (no per-file messages) + auto tracked = files_in_patch(q, patch); + out_line("Removing patch " + patch_path_display(q, patch)); + for (const auto &f : tracked) { + restore_file(q, patch, f); + } + std::string pc_dir = pc_patch_dir(q, patch); + if (is_directory(pc_dir)) delete_dir_recursive(pc_dir); + q.applied.pop_back(); + if (!write_applied_checked(q, q.applied)) return 1; + if (!q.applied.empty()) { + out_line("Now at patch " + + patch_path_display(q, q.applied.back())); + } else { + out_line("No patches applied"); + } + } + + // Remove from series + auto new_series = q.series; + new_series.erase(new_series.begin() + *idx); + if (!write_series_checked(q, new_series)) { + return 1; + } + q.series = std::move(new_series); + + // Optionally remove the patch file + if (opt_remove) { + std::string patch_file = path_join(q.work_dir, q.patches_dir, patch); + if (opt_backup) { + std::string backup = patch_file + "~"; + if (file_exists(patch_file) && !rename_path(patch_file, backup)) { + err_line("Failed to rename " + patch_file + " to " + backup); + return 1; + } + } else if (file_exists(patch_file) && !delete_file(patch_file)) { + err_line("Failed to delete " + patch_file); + return 1; + } + } + + out_line("Removed patch " + patch_path_display(q, patch)); + return 0; +} + +int cmd_rename(QuiltState &q, int argc, char **argv) { + std::string old_patch; + std::string new_name; + + for (int i = 1; i < argc; ++i) { + std::string_view arg = argv[i]; + if (arg == "-P" && i + 1 < argc) { + old_patch = strip_patches_prefix(q, argv[++i]); + } else if (arg[0] == '-') { + err("Unrecognized option: "); err_line(arg); + return 1; + } else { + new_name = strip_patches_prefix(q, arg); + } + } + + // Default to top patch + if (old_patch.empty()) { + if (q.applied.empty()) { + err_line("No patches applied"); + return 1; + } + old_patch = q.applied.back(); + } + + if (new_name.empty()) { + err_line("Usage: quilt rename [-P patch] new_name"); + return 1; + } + + // Verify old patch exists in series + auto idx = q.find_in_series(old_patch); + if (!idx) { + err("Patch "); err(old_patch); err_line(" is not in series"); + return 1; + } + + // Verify new name doesn't exist in series + auto new_idx = q.find_in_series(new_name); + if (new_idx) { + err("Patch "); err(patch_path_display(q, new_name)); + err_line(" exists already, please choose a different name"); + return 1; + } + + // Rename patch file + std::string old_file = path_join(q.work_dir, q.patches_dir, old_patch); + std::string new_file = path_join(q.work_dir, q.patches_dir, new_name); + bool renamed_patch_file = false; + if (file_exists(old_file)) { + // Ensure target directory exists + std::string new_dir = dirname(new_file); + if (!is_directory(new_dir)) { + if (!make_dirs(new_dir)) { + err_line("Failed to create " + new_dir); + return 1; + } + } + if (!rename_path(old_file, new_file)) { + err_line("Failed to rename " + old_file + " to " + new_file); + return 1; + } + renamed_patch_file = true; + } + + // If patch is applied: rename in applied-patches and .pc/ dir + auto new_applied = q.applied; + bool renamed_pc_dir = false; + if (q.is_applied(old_patch)) { + for (auto &a : new_applied) { + if (a == old_patch) { + a = new_name; + break; + } + } + std::string old_pc = pc_patch_dir(q, old_patch); + std::string new_pc = pc_patch_dir(q, new_name); + if (is_directory(old_pc)) { + if (!rename_path(old_pc, new_pc)) { + if (renamed_patch_file) { + rename_path(new_file, old_file); + } + err_line("Failed to rename " + old_pc + " to " + new_pc); + return 1; + } + renamed_pc_dir = true; + } + } + + // Migrate per-patch metadata before writing series + std::string old_key(old_patch); + auto sl_it = q.patch_strip_level.find(old_key); + int saved_strip = -1; + bool saved_reversed = false; + if (sl_it != q.patch_strip_level.end()) { + saved_strip = sl_it->second; + q.patch_strip_level[new_name] = sl_it->second; + q.patch_strip_level.erase(sl_it); + } + if (q.patch_reversed.erase(old_key)) { + saved_reversed = true; + q.patch_reversed.insert(new_name); + } + + auto new_series = q.series; + new_series[checked_cast(*idx)] = new_name; + if (!write_series_checked(q, new_series)) { + // Undo metadata migration + if (saved_strip >= 0) { + q.patch_strip_level[old_key] = saved_strip; + q.patch_strip_level.erase(new_name); + } + if (saved_reversed) { + q.patch_reversed.erase(new_name); + q.patch_reversed.insert(old_key); + } + if (renamed_pc_dir) { + rename_path(pc_patch_dir(q, new_name), pc_patch_dir(q, old_patch)); + } + if (renamed_patch_file) { + rename_path(new_file, old_file); + } + return 1; + } + + if (q.is_applied(old_patch) && !write_applied_checked(q, new_applied)) { + write_series_checked(q, q.series); + if (saved_strip >= 0) { + q.patch_strip_level[old_key] = saved_strip; + q.patch_strip_level.erase(new_name); + } + if (saved_reversed) { + q.patch_reversed.erase(new_name); + q.patch_reversed.insert(old_key); + } + if (renamed_pc_dir) { + rename_path(pc_patch_dir(q, new_name), pc_patch_dir(q, old_patch)); + } + if (renamed_patch_file) { + rename_path(new_file, old_file); + } + return 1; + } + + q.series = std::move(new_series); + if (q.is_applied(old_patch)) { + q.applied = std::move(new_applied); + } + + out("Patch "); out(patch_path_display(q, old_patch)); + out(" renamed to "); out_line(patch_path_display(q, new_name)); + return 0; +} + +int cmd_import(QuiltState &q, int argc, char **argv) { + int strip_level = -1; + std::string target_name; + bool force = false; + char dup_mode = 0; // o=overwrite, a=append, n=next + bool reversed = false; + std::vector patchfiles; + + for (int i = 1; i < argc; ++i) { + std::string_view arg = argv[i]; + if (arg == "-p" && i + 1 < argc) { + strip_level = checked_cast(parse_int(argv[++i])); + } else if (arg == "-R") { + reversed = true; + } else if (arg == "-P" && i + 1 < argc) { + target_name = strip_patches_prefix(q, argv[++i]); + } else if (arg == "-f") { + force = true; + } else if (arg == "-d" && i + 1 < argc) { + dup_mode = argv[++i][0]; + } else if (arg[0] == '-') { + err("Unrecognized option: "); err_line(arg); + return 1; + } else { + patchfiles.emplace_back(arg); + } + } + + if (patchfiles.empty()) { + err_line("Usage: quilt import [-p num] [-R] [-P patch] [-f] [-d {o|a|n}] patchfile ..."); + return 1; + } + + if (!target_name.empty() && patchfiles.size() > 1) { + err_line("Option `-P' can only be used when importing a single patch"); + return 1; + } + + if (!ensure_pc_dir(q)) { + return 1; + } + + // Ensure patches dir exists + std::string patches_abs = path_join(q.work_dir, q.patches_dir); + if (!is_directory(patches_abs)) { + if (!make_dirs(patches_abs)) { + err_line("Failed to create " + patches_abs); + return 1; + } + } + + for (const auto &patchfile : patchfiles) { + // Determine target name + std::string name; + if (!target_name.empty()) { + name = target_name; + } else { + name = basename(patchfile); + } + + std::string dest = path_join(q.work_dir, q.patches_dir, name); + + // Check if target exists in series + auto existing = q.find_in_series(name); + if (existing && q.is_applied(name)) { + err_line("Patch " + patch_path_display(q, name) + + " is applied"); + return 1; + } + if (existing && !force) { + err_line("Patch " + patch_path_display(q, name) + + " exists. Replace with -f."); + return 1; + } + + // Ensure parent directory of dest exists (for subdir patch names) + std::string dest_dir = dirname(dest); + if (!is_directory(dest_dir)) { + if (!make_dirs(dest_dir)) { + err_line("Failed to create " + dest_dir); + return 1; + } + } + + // Copy patchfile to patches/, handling -d header mode + if (existing && force && dup_mode && dup_mode != 'n') { + // Merge headers based on -d mode + std::string old_content = read_file(dest); + std::string new_content = read_file(patchfile); + std::string old_hdr = extract_header(old_content); + std::string new_hdr = extract_header(new_content); + std::string merged_header; + if (dup_mode == 'o') { + merged_header = old_hdr; + } else if (dup_mode == 'a') { + merged_header = old_hdr; + if (!merged_header.empty() && merged_header.back() != '\n') + merged_header += '\n'; + merged_header += "---\n"; + merged_header += new_hdr; + } + std::string result = replace_header(new_content, merged_header); + if (!write_file(dest, result)) { + err_line("Failed to write " + dest); + return 1; + } + } else if (existing && force && !dup_mode) { + // Both patches exist and no -d flag: check if both have headers + std::string old_content = read_file(dest); + std::string new_content = read_file(patchfile); + std::string old_hdr = extract_header(old_content); + std::string new_hdr = extract_header(new_content); + if (!old_hdr.empty() && !new_hdr.empty() && old_hdr != new_hdr) { + err_line("Patch headers differ:"); + err_line("@@ -1 +1 @@"); + err_line("-" + old_hdr); + err_line("+" + new_hdr); + err_line("Please use -d {o|a|n} to specify which patch " + "header(s) to keep."); + return 1; + } + if (!copy_file(patchfile, dest)) { + err_line("Failed to copy " + patchfile + " to " + dest); + return 1; + } + } else { + if (!copy_file(patchfile, dest)) { + err_line("Failed to copy " + patchfile + " to " + dest); + return 1; + } + } + + // Update per-patch metadata + if (strip_level >= 0 && strip_level != 1) { + q.patch_strip_level[name] = strip_level; + } else if (strip_level < 0) { + q.patch_strip_level.erase(name); + } + if (reversed) { + q.patch_reversed.insert(name); + } else { + q.patch_reversed.erase(name); + } + + // Add to series if not already present + if (!existing) { + // Insert after top applied patch, or at end if none applied + ptrdiff_t top_idx = q.top_index(); + auto new_series = q.series; + if (top_idx >= 0 && top_idx + 1 < std::ssize(new_series)) { + new_series.insert(new_series.begin() + top_idx + 1, name); + } else { + new_series.push_back(name); + } + if (!write_series_checked(q, new_series)) { + delete_file(dest); + return 1; + } + q.series = std::move(new_series); + } else { + // Overwriting existing patch — rewrite series for metadata update + if (!write_series_checked(q, q.series)) { + return 1; + } + } + + if (existing && force) { + out_line("Replacing patch " + patch_path_display(q, name) + + " with new version"); + } else { + out_line("Importing patch " + patchfile + + " (stored as " + patch_path_display(q, name) + ")"); + } + } + + return 0; +} + +// Remove an existing diffstat section from a header. +// Detects "---" separator followed by " file | N ++--" lines ending +// with a "N file(s) changed" summary line. +static std::string strip_diffstat(std::string_view header) { + auto lines = split_lines(header); + std::string result; + for (ptrdiff_t i = 0; i < std::ssize(lines); ++i) { + const auto &line = lines[checked_cast(i)]; + + // Detect "---" separator followed by diffstat, or bare diffstat + ptrdiff_t ds_start = i; + if (line == "---" && i + 1 < std::ssize(lines)) { + ds_start = i + 1; + } + + const auto &first = lines[checked_cast(ds_start)]; + if (!first.empty() && first[0] == ' ' && + str_find(first, '|') >= 0) { + bool found_summary = false; + ptrdiff_t summary_end = -1; + for (ptrdiff_t j = ds_start; j < std::ssize(lines); ++j) { + const auto &l = lines[checked_cast(j)]; + if (l.find("changed") != std::string::npos && + l.find("file") != std::string::npos) { + found_summary = true; + summary_end = j; + break; + } + if (l.empty() || (l[0] != ' ' && str_find(l, '|') < 0)) + break; + } + if (found_summary) { + // Keep the "---" separator if the diffstat followed it + if (ds_start != i) { + result += line; + result += '\n'; + } + i = summary_end; + if (i + 1 < std::ssize(lines) && lines[checked_cast(i + 1)].empty()) + i++; + continue; + } + } + result += line; + result += '\n'; + } + return result; +} + +// Strip trailing whitespace from each line of a header. +static std::string strip_header_trailing_ws(std::string_view header) { + std::string result; + auto lines = split_lines(header); + for (const auto &line : lines) { + if (line.empty()) { + result += '\n'; + continue; + } + // Strip \r from CRLF before checking for trailing whitespace + std::string_view l = line; + if (!l.empty() && l.back() == '\r') l.remove_suffix(1); + auto end = l.find_last_not_of(" \t"); + if (end == std::string::npos) { + result += '\n'; + } else { + result += l.substr(0, end + 1); + result += '\n'; + } + } + return result; +} + +static constexpr const char *dep3_template = + "Description: \n" + " \n" + "Author: \n" + "Origin: , \n" + "Bug: \n" + "Bug-Debian: https://bugs.debian.org/\n" + "Forwarded: \n" + "Applied-Upstream: \n" + "Last-Update: \n"; + +int cmd_header(QuiltState &q, int argc, char **argv) { + enum Mode { PRINT, APPEND, REPLACE, EDIT }; + Mode mode = PRINT; + bool opt_backup = false; + bool opt_dep3 = false; + bool opt_strip_ds = false; + bool opt_strip_ws = false; + std::string_view patch_arg; + + for (int i = 1; i < argc; ++i) { + std::string_view arg = argv[i]; + if (arg == "-a") { + mode = APPEND; + } else if (arg == "-r") { + mode = REPLACE; + } else if (arg == "-e") { + mode = EDIT; + } else if (arg == "--backup") { + opt_backup = true; + } else if (arg == "--dep3") { + opt_dep3 = true; + } else if (arg == "--strip-diffstat") { + opt_strip_ds = true; + } else if (arg == "--strip-trailing-whitespace") { + opt_strip_ws = true; + } else if (arg[0] == '-') { + err("Unrecognized option: "); err_line(arg); + return 1; + } else { + patch_arg = strip_patches_prefix(q, arg); + } + } + + // Determine patch + std::string_view patch; + if (!patch_arg.empty()) { + patch = patch_arg; + // Verify patch is in series + if (!q.find_in_series(patch)) { + err("Patch "); err(patch); + err_line(" is not in series"); + return 1; + } + } else if (!q.applied.empty()) { + patch = q.applied.back(); + } else { + err_line("No patches applied"); + return 1; + } + + std::string patch_file = path_join(q.work_dir, q.patches_dir, patch); + std::string content = read_file(patch_file); + + // Helper to apply --strip-diffstat and --strip-trailing-whitespace + auto apply_strip = [&](std::string h) { + if (opt_strip_ds) h = strip_diffstat(h); + if (opt_strip_ws) h = strip_header_trailing_ws(h); + return h; + }; + + if (mode == PRINT) { + std::string header = apply_strip(extract_header(content)); + out(header); + return 0; + } + + if (mode == APPEND) { + std::string stdin_data = read_stdin(); + std::string old_header = extract_header(content); + std::string new_header = apply_strip(old_header + stdin_data); + if (opt_backup) { + copy_file(patch_file, patch_file + "~"); + } + std::string new_content = replace_header(content, new_header); + write_file(patch_file, new_content); + out_line("Appended text to header of patch " + + patch_path_display(q, patch)); + return 0; + } + + if (mode == REPLACE) { + std::string stdin_data = read_stdin(); + std::string new_header = apply_strip(stdin_data); + if (opt_backup) { + copy_file(patch_file, patch_file + "~"); + } + std::string new_content = replace_header(content, new_header); + write_file(patch_file, new_content); + out_line("Replaced header of patch " + + patch_path_display(q, patch)); + return 0; + } + + if (mode == EDIT) { + std::string editor = get_env("EDITOR"); + if (editor.empty()) editor = "vi"; + + std::string header = extract_header(content); + // Insert DEP-3 template if header is empty and --dep3 given + if (opt_dep3 && trim(header).empty()) { + header = dep3_template; + } + std::string tmp_file = path_join(q.work_dir, ".pc/.quilt_header_tmp"); + write_file(tmp_file, header); + + int rc = run_cmd_tty({editor, tmp_file}); + if (rc != 0) { + delete_file(tmp_file); + err_line("Editor exited with error"); + return 1; + } + + std::string new_header = apply_strip(read_file(tmp_file)); + delete_file(tmp_file); + + if (opt_backup) { + copy_file(patch_file, patch_file + "~"); + } + std::string new_content = replace_header(content, new_header); + write_file(patch_file, new_content); + out_line("Replaced header of patch " + patch_path_display(q, patch)); + return 0; + } + + return 0; +} + +int cmd_files(QuiltState &q, int argc, char **argv) { + bool opt_verbose = false; + bool opt_all = false; + bool opt_labels = false; + std::string combine_patch; + std::string_view patch_arg; + + for (int i = 1; i < argc; ++i) { + std::string_view arg = argv[i]; + if (arg == "-v") { + opt_verbose = true; + } else if (arg == "-a") { + opt_all = true; + } else if (arg == "-l") { + opt_labels = true; + } else if (arg == "--combine" && i + 1 < argc) { + combine_patch = argv[++i]; + } else if (arg[0] == '-') { + err("Unrecognized option: "); err_line(arg); + return 1; + } else { + patch_arg = strip_patches_prefix(q, arg); + } + } + + // Determine target patch (topmost or specified) + std::string target_patch; + if (!patch_arg.empty()) { + target_patch = patch_arg; + } else if (!q.applied.empty()) { + target_patch = q.applied.back(); + } else if (!opt_all) { + err_line("No patches applied"); + return 1; + } + + // Build list of patches to show files for + std::vector patches_to_show; + if (opt_all) { + patches_to_show = q.applied; + } else if (!combine_patch.empty()) { + // Range from combine_patch through target_patch + std::string start = combine_patch; + if (start == "-") { + if (q.applied.empty()) { + err_line("No patches applied"); + return 1; + } + start = q.applied.front(); + } else { + start = strip_patches_prefix(q, start); + } + bool in_range = false; + for (const auto &a : q.applied) { + if (a == start) in_range = true; + if (in_range) patches_to_show.push_back(a); + if (a == target_patch) break; + } + if (!in_range || patches_to_show.empty()) { + err("Patch "); + err(start); + err_line(" not applied"); + return 1; + } + } else { + patches_to_show.push_back(target_patch); + } + + // With labels (-l): iterate patches, output per-patch file listings + if (opt_labels) { + for (const auto &patch : patches_to_show) { + std::vector file_list; + if (q.is_applied(patch)) { + file_list = files_in_patch(q, patch); + } else { + std::string patch_file = path_join(q.work_dir, q.patches_dir, patch); + std::string content = read_file(patch_file); + file_list = parse_patch_files(content); + } + std::ranges::sort(file_list); + for (const auto &f : file_list) { + out_line(patch + " " + f); + } + } + } else { + // Collect all files across patches + std::vector all_files; + for (const auto &patch : patches_to_show) { + std::vector file_list; + if (q.is_applied(patch)) { + file_list = files_in_patch(q, patch); + } else { + std::string patch_file = path_join(q.work_dir, q.patches_dir, patch); + std::string content = read_file(patch_file); + file_list = parse_patch_files(content); + } + for (auto &f : file_list) { + all_files.push_back(std::move(f)); + } + } + std::ranges::sort(all_files); + for (const auto &f : all_files) { + if (opt_verbose) { + out_line(" " + f); + } else { + out_line(f); + } + } + } + + return 0; +} + +int cmd_patches(QuiltState &q, int argc, char **argv) { + bool opt_verbose = false; + std::vector target_files; + + for (int i = 1; i < argc; ++i) { + std::string_view arg = argv[i]; + if (arg == "-v") { + opt_verbose = true; + } else if (arg == "--color" || arg.starts_with("--color=")) { + if (arg.starts_with("--color=")) { + auto val = arg.substr(8); + if (val != "always" && val != "auto" && val != "never") { + err("Invalid --color value: "); err_line(val); + return 1; + } + } + } else if (arg[0] == '-') { + err("Unrecognized option: "); err_line(arg); + return 1; + } else { + target_files.push_back(subdir_path(q, arg)); + } + } + + if (target_files.empty()) { + err_line("Usage: quilt patches [-v] [--color] file [files...]"); + return 1; + } + + for (const auto &patch : q.series) { + bool touches = false; + + if (q.is_applied(patch)) { + // Check .pc// + std::string pc_dir = pc_patch_dir(q, patch); + for (const auto &tf : target_files) { + std::string check = path_join(pc_dir, tf); + if (file_exists(check)) { + touches = true; + break; + } + } + } else { + // Parse patch file for references + std::string patch_file = path_join(q.work_dir, q.patches_dir, patch); + std::string content = read_file(patch_file); + auto patched_files = parse_patch_files(content); + for (const auto &tf : target_files) { + for (const auto &pf : patched_files) { + if (pf == tf) { + touches = true; + break; + } + } + if (touches) break; + } + } + + if (touches) { + std::string display = patch; + if (opt_verbose) { + // Show applied status: = for top, + for other applied, space for unapplied + if (!q.applied.empty() && patch == q.applied.back()) { + out_line("= " + display); + } else if (q.is_applied(patch)) { + out_line("+ " + display); + } else { + out_line(" " + display); + } + } else { + out_line(display); + } + } + } + + return 0; +} + +int cmd_fold(QuiltState &q, int argc, char **argv) { + bool opt_reverse = false; + bool opt_quiet = false; + bool opt_force = false; + int strip_level = 1; + + for (int i = 1; i < argc; ++i) { + std::string_view arg = argv[i]; + if (arg == "-R") { + opt_reverse = true; + } else if (arg == "-q") { + opt_quiet = true; + } else if (arg == "-f") { + opt_force = true; + } else if (arg == "-p" && i + 1 < argc) { + strip_level = checked_cast(parse_int(argv[++i])); + } else if (arg.starts_with("-p") && arg.size() > 2 && + arg[2] >= '0' && arg[2] <= '9') { + strip_level = checked_cast(parse_int(arg.substr(2))); + } else if (arg[0] == '-') { + err("Unrecognized option: "); err_line(arg); + return 1; + } + } + + if (q.applied.empty()) { + err_line("No patches applied"); + return 1; + } + + std::string top = q.applied.back(); + std::string stdin_data = read_stdin(); + + if (stdin_data.empty()) { + return 0; + } + + // Parse the incoming patch to find affected files + auto affected_files = parse_patch_files(stdin_data, strip_level); + + // Track new files in the current patch + auto currently_tracked = files_in_patch(q, top); + for (const auto &f : affected_files) { + bool already_tracked = false; + for (const auto &t : currently_tracked) { + if (t == f) { already_tracked = true; break; } + } + if (!already_tracked) { + backup_file(q, top, f); + } + } + + // Apply patch using built-in patch engine + PatchOptions patch_opts; + patch_opts.strip_level = strip_level; + patch_opts.reverse = opt_reverse; + patch_opts.force = opt_force; + patch_opts.quiet = opt_quiet; + auto extra_patch_opts = shell_split(get_env("QUILT_PATCH_OPTS")); + for (const auto &opt : extra_patch_opts) { + std::string_view o = opt; + if (o == "-R") patch_opts.reverse = true; + else if (o == "-f" || o == "--force") patch_opts.force = true; + else if (o == "-s") patch_opts.quiet = true; + else if (o == "-E") patch_opts.remove_empty = true; + else if (o.starts_with("--fuzz=")) { + patch_opts.fuzz = checked_cast(parse_int(o.substr(7))); + } + } + + PatchResult r = builtin_patch(stdin_data, patch_opts); + if (!opt_quiet && !r.out.empty()) { + out(r.out); + } + if (!r.err.empty()) err(r.err); + + if (r.exit_code != 0 && !opt_force) { + return 1; + } + + return 0; +} + +int cmd_fork(QuiltState &q, int argc, char **argv) { + if (q.applied.empty()) { + err_line("No patches applied"); + return 1; + } + + std::string old_name = q.applied.back(); + std::string new_name; + + for (int i = 1; i < argc; ++i) { + std::string_view arg = argv[i]; + if (arg[0] == '-') { + err("Unrecognized option: "); err_line(arg); + return 1; + } + new_name = strip_patches_prefix(q, arg); + break; + } + + // Generate default name if none given: increment "-N" suffix before extension + if (new_name.empty()) { + auto dot = str_rfind(old_name, '.'); + std::string base; + std::string ext; + if (dot > 0) { + base = old_name.substr(0, checked_cast(dot)); + ext = old_name.substr(checked_cast(dot)); + } else { + base = old_name; + } + // Check for existing -N suffix and increment it + auto dash = str_rfind(base, '-'); + if (dash >= 0) { + std::string_view suffix = std::string_view(base).substr( + checked_cast(dash) + 1); + int n = 0; + auto [ptr, ec] = std::from_chars(suffix.data(), + suffix.data() + suffix.size(), n); + if (ec == std::errc{} && ptr == suffix.data() + suffix.size()) { + new_name = base.substr(0, checked_cast(dash) + 1) + + std::to_string(n + 1) + ext; + } else { + new_name = base + "-2" + ext; + } + } else { + new_name = base + "-2" + ext; + } + } + + // Check that the new name doesn't already exist in series + if (q.find_in_series(new_name)) { + err("Patch "); err(new_name); err_line(" already exists in series"); + return 1; + } + + auto idx = q.find_in_series(old_name); + if (!idx) { + err("Patch "); err(old_name); err_line(" is not in series"); + return 1; + } + + // Copy patch file + std::string old_file = path_join(q.work_dir, q.patches_dir, old_name); + std::string new_file = path_join(q.work_dir, q.patches_dir, new_name); + bool copied_patch_file = false; + if (file_exists(old_file)) { + std::string new_dir = dirname(new_file); + if (!is_directory(new_dir)) { + if (!make_dirs(new_dir)) { + err_line("Failed to create " + new_dir); + return 1; + } + } + if (!copy_file(old_file, new_file)) { + err_line("Failed to copy " + old_file + " to " + new_file); + return 1; + } + copied_patch_file = true; + } + + // Rename .pc/ directory + std::string old_pc = pc_patch_dir(q, old_name); + std::string new_pc = pc_patch_dir(q, new_name); + bool renamed_pc_dir = false; + if (is_directory(old_pc)) { + if (!rename_path(old_pc, new_pc)) { + if (copied_patch_file) { + delete_file(new_file); + } + err_line("Failed to rename " + old_pc + " to " + new_pc); + return 1; + } + renamed_pc_dir = true; + } + + // Migrate per-patch metadata before writing series + auto sl_it = q.patch_strip_level.find(old_name); + int saved_strip = -1; + bool saved_reversed = false; + if (sl_it != q.patch_strip_level.end()) { + saved_strip = sl_it->second; + q.patch_strip_level[new_name] = sl_it->second; + q.patch_strip_level.erase(sl_it); + } + if (q.patch_reversed.erase(old_name)) { + saved_reversed = true; + q.patch_reversed.insert(new_name); + } + + auto new_series = q.series; + new_series[checked_cast(*idx)] = new_name; + if (!write_series_checked(q, new_series)) { + if (saved_strip >= 0) { + q.patch_strip_level[old_name] = saved_strip; + q.patch_strip_level.erase(new_name); + } + if (saved_reversed) { + q.patch_reversed.erase(new_name); + q.patch_reversed.insert(old_name); + } + if (renamed_pc_dir) { + rename_path(new_pc, old_pc); + } + if (copied_patch_file) { + delete_file(new_file); + } + return 1; + } + + auto new_applied = q.applied; + for (auto &a : new_applied) { + if (a == old_name) { + a = new_name; + break; + } + } + if (!write_applied_checked(q, new_applied)) { + write_series_checked(q, q.series); + if (saved_strip >= 0) { + q.patch_strip_level[old_name] = saved_strip; + q.patch_strip_level.erase(new_name); + } + if (saved_reversed) { + q.patch_reversed.erase(new_name); + q.patch_reversed.insert(old_name); + } + if (renamed_pc_dir) { + rename_path(new_pc, old_pc); + } + if (copied_patch_file) { + delete_file(new_file); + } + return 1; + } + + q.series = std::move(new_series); + q.applied = std::move(new_applied); + + out_line("Fork of patch " + old_name + + " created as " + new_name); + return 0; +} + +int cmd_upgrade(QuiltState &, int argc, char **argv) +{ + for (int i = 1; i < argc; ++i) { + std::string_view arg = argv[i]; + if (arg == "-h" || arg == "--help") { + out_line("Usage: quilt upgrade"); + out_line(""); + out_line("Upgrade the metadata in the .pc/ directory from version 1 to"); + out_line("version 2. This command does nothing because quilt.cpp only"); + out_line("supports the version 2 format."); + return 0; + } + if (arg[0] == '-') { + err("Unrecognized option: "); err_line(arg); + return 1; + } + } + return 0; +} + +// === src/cmd_graph.cpp === + +// This is free and unencumbered software released into the public domain. +#include +#include +#include +#include +#include +#include +#include + +namespace { + +struct LineRanges { + bool computed = false; + std::vector left; + std::vector right; +}; + +struct GraphNode { + int number = 0; + std::string name; + std::map files; + std::vector attrs; +}; + +struct EdgeData { + std::vector names; +}; + +using EdgeKey = std::pair; + +static bool is_number(std::string_view value) { + if (value.empty()) return false; + for (char c : value) { + if (c < '0' || c > '9') return false; + } + return true; +} + +static bool is_zero_length_file(std::string_view path) { + return file_exists(path) && read_file(path).empty(); +} + +static std::string dot_escape(std::string_view text) { + std::string escaped; + escaped.reserve(checked_cast(std::ssize(text))); + for (char c : text) { + if (c == '\\' || c == '"') { + escaped += '\\'; + escaped += c; + } else if (c == '\n') { + escaped += "\\n"; + } else { + escaped += c; + } + } + return escaped; +} + +static int parse_hunk_count(const std::ssub_match &match) { + if (!match.matched || match.str().empty()) { + return 1; + } + return checked_cast(parse_int(match.str())); +} + +static LineRanges parse_ranges(std::string_view diff_text) { + static const std::regex hunk_regex( + R"(^@@ -([0-9]+)(?:,([0-9]+))? \+([0-9]+)(?:,([0-9]+))? @@)"); + + LineRanges ranges; + ranges.computed = true; + for (const auto &line : split_lines(diff_text)) { + std::smatch match; + if (!std::regex_search(line, match, hunk_regex)) continue; + + int old_start = checked_cast(parse_int(match[1].str())); + int old_count = parse_hunk_count(match[2]); + int new_start = checked_cast(parse_int(match[3].str())); + int new_count = parse_hunk_count(match[4]); + + ranges.left.push_back(new_start); + ranges.left.push_back(new_start + new_count); + ranges.right.push_back(old_start); + ranges.right.push_back(old_start + old_count); + } + return ranges; +} + +static std::optional next_node_for_file(const std::vector &nodes, + int index, + std::string_view file) { + for (int i = index + 1; i < std::ssize(nodes); ++i) { + if (nodes[checked_cast(i)].files.contains(std::string(file))) { + return i; + } + } + return std::nullopt; +} + +static void compute_ranges(const QuiltState &q, + std::vector &nodes, + int index, + std::string_view file, + int context_lines) { + auto it = nodes[checked_cast(index)].files.find(std::string(file)); + if (it == nodes[checked_cast(index)].files.end() || it->second.computed) return; + + LineRanges &ranges = it->second; + ranges.computed = true; + + std::string old_path = path_join(pc_patch_dir(q, nodes[checked_cast(index)].name), file); + std::string new_path; + auto next = next_node_for_file(nodes, index, file); + if (next.has_value()) { + new_path = path_join(pc_patch_dir(q, nodes[checked_cast(*next)].name), file); + } else { + new_path = path_join(q.work_dir, file); + } + + bool old_missing = is_zero_length_file(old_path); + bool new_missing = is_zero_length_file(new_path); + if (old_missing && new_missing) { + return; + } + + DiffResult diff = builtin_diff( + old_missing ? "/dev/null" : std::string_view(old_path), + new_missing ? "/dev/null" : std::string_view(new_path), + context_lines); + if (diff.exit_code == 0) { + return; + } + + ranges = parse_ranges(diff.output); +} + +static bool is_conflict(const QuiltState &q, + std::vector &nodes, + int from, + int to, + std::string_view file, + int context_lines) { + compute_ranges(q, nodes, from, file, context_lines); + compute_ranges(q, nodes, to, file, context_lines); + + const auto file_key = std::string(file); + const auto &a = nodes[checked_cast(from)].files[file_key].right; + const auto &b = nodes[checked_cast(to)].files[file_key].left; + + ptrdiff_t ia = 0; + ptrdiff_t ib = 0; + while (ia < std::ssize(a) && ib < std::ssize(b)) { + ptrdiff_t rem_a = std::ssize(a) - ia; + ptrdiff_t rem_b = std::ssize(b) - ib; + if (a[checked_cast(ia)] < b[checked_cast(ib)]) { + if ((rem_b % 2) == 1) return true; + ++ia; + } else if (a[checked_cast(ia)] > b[checked_cast(ib)]) { + if ((rem_a % 2) == 1) return true; + ++ib; + } else { + if ((rem_a % 2) == (rem_b % 2)) return true; + ++ia; + ++ib; + } + } + return false; +} + +static void add_edge(std::map &edges, + int earlier, + int later, + std::string_view file) { + auto &edge = edges[{earlier, later}]; + edge.names.emplace_back(file); +} + +static std::set collect_reachable(const std::map &edges, + int start, + bool forward) { + std::map> adjacency; + for (const auto &[key, value] : edges) { + (void)value; + int from = key.first; + int to = key.second; + if (forward) { + adjacency[from].push_back(to); + } else { + adjacency[to].push_back(from); + } + } + + std::set seen; + std::vector stack = {start}; + while (!stack.empty()) { + int node = stack.back(); + stack.pop_back(); + if (!seen.insert(node).second) continue; + auto it = adjacency.find(node); + if (it == adjacency.end()) continue; + for (int next : it->second) { + stack.push_back(next); + } + } + return seen; +} + +static bool has_alternate_path(int from, + int to, + const std::map> &adjacency, + EdgeKey skip_edge) { + std::vector stack = {from}; + std::set seen; + while (!stack.empty()) { + int node = stack.back(); + stack.pop_back(); + if (!seen.insert(node).second) continue; + + auto it = adjacency.find(node); + if (it == adjacency.end()) continue; + for (int next : it->second) { + if (EdgeKey{node, next} == skip_edge) continue; + if (next == to) return true; + stack.push_back(next); + } + } + return false; +} + +static void reduce_edges(std::map &edges) { + std::map> adjacency; + for (const auto &[key, value] : edges) { + (void)value; + adjacency[key.first].push_back(key.second); + } + + std::vector to_remove; + for (const auto &[key, value] : edges) { + (void)value; + if (has_alternate_path(key.first, key.second, adjacency, key)) { + to_remove.push_back(key); + } + } + + for (const auto &key : to_remove) { + edges.erase(key); + } +} + +static std::string format_len_attr(int from, int to) { + std::ostringstream value; + value << std::fixed << std::setprecision(2) + << std::log(static_cast(std::abs(to - from) + 3)); + return "len=\"" + value.str() + "\""; +} + +static std::string render_dot(const std::vector &nodes, + std::map edges, + std::set used_nodes, + bool reduce, + bool edge_labels) { + if (reduce) { + reduce_edges(edges); + used_nodes.clear(); + for (const auto &[key, value] : edges) { + (void)value; + used_nodes.insert(key.first); + used_nodes.insert(key.second); + } + // Preserve the selected node (style=bold) even if it has no edges + for (const auto &node : nodes) { + for (const auto &attr : node.attrs) { + if (attr == "style=bold") { + used_nodes.insert(node.number); + break; + } + } + } + } + + std::string dot = "digraph dependencies {\n"; + for (const auto &node : nodes) { + if (!used_nodes.contains(node.number)) continue; + + std::vector attrs = node.attrs; + attrs.push_back("label=\"" + dot_escape(node.name) + "\""); + + dot += "\tn" + std::to_string(node.number); + if (!attrs.empty()) { + dot += " ["; + for (ptrdiff_t i = 0; i < std::ssize(attrs); ++i) { + if (i != 0) dot += ","; + dot += attrs[checked_cast(i)]; + } + dot += "]"; + } + dot += ";\n"; + } + + for (auto &[key, edge] : edges) { + std::ranges::sort(edge.names); + auto [first, last] = std::ranges::unique(edge.names); + edge.names.erase(first, last); + + std::vector attrs; + if (edge_labels && !edge.names.empty()) { + std::string label; + for (ptrdiff_t i = 0; i < std::ssize(edge.names); ++i) { + if (i != 0) label += "\\n"; + label += dot_escape(edge.names[checked_cast(i)]); + } + attrs.push_back("label=\"" + label + "\""); + } + attrs.push_back(format_len_attr(key.first, key.second)); + + dot += "\tn" + std::to_string(key.first) + " -> n" + std::to_string(key.second); + dot += " ["; + for (ptrdiff_t i = 0; i < std::ssize(attrs); ++i) { + if (i != 0) dot += ","; + dot += attrs[checked_cast(i)]; + } + dot += "];\n"; + } + + dot += "}\n"; + return dot; +} + +} // namespace + +int cmd_graph(QuiltState &q, int argc, char **argv) { + bool opt_all = false; + bool opt_reduce = false; + bool opt_edge_labels = false; + std::optional opt_lines; + std::string_view patch_arg; + + for (int i = 1; i < argc; ++i) { + std::string_view arg = argv[i]; + if (arg == "--all") { + opt_all = true; + } else if (arg == "--reduce") { + opt_reduce = true; + } else if (arg == "--lines") { + opt_lines = 2; + if (i + 1 < argc && is_number(argv[i + 1])) { + opt_lines = checked_cast(parse_int(argv[++i])); + } + } else if (arg.starts_with("--lines=")) { + std::string value(arg.substr(8)); + if (!is_number(value)) { + err_line("Usage: quilt graph [--all] [--reduce] [--lines[=num]] [--edge-labels=files] [-T ps] [patch]"); + return 1; + } + opt_lines = checked_cast(parse_int(value)); + } else if (arg == "--edge-labels") { + if (i + 1 >= argc || std::string_view(argv[i + 1]) != "files") { + err_line("Usage: quilt graph [--all] [--reduce] [--lines[=num]] [--edge-labels=files] [-T ps] [patch]"); + return 1; + } + opt_edge_labels = true; + ++i; + } else if (arg == "--edge-labels=files") { + opt_edge_labels = true; + } else if (arg == "-T") { + if (i + 1 >= argc || std::string_view(argv[i + 1]) != "ps") { + err_line("Usage: quilt graph [--all] [--reduce] [--lines[=num]] [--edge-labels=files] [-T ps] [patch]"); + return 1; + } + ++i; + err_line("quilt graph -T ps: not implemented"); + return 1; + } else if (arg == "-Tps") { + err_line("quilt graph -T ps: not implemented"); + return 1; + } else if (!arg.empty() && arg[0] == '-') { + err_line("Usage: quilt graph [--all] [--reduce] [--lines[=num]] [--edge-labels=files] [-T ps] [patch]"); + return 1; + } else if (!patch_arg.empty()) { + err_line("Usage: quilt graph [--all] [--reduce] [--lines[=num]] [--edge-labels=files] [-T ps] [patch]"); + return 1; + } else { + patch_arg = strip_patches_prefix(q, arg); + } + } + + if (!patch_arg.empty() && opt_all) { + err_line("Usage: quilt graph [--all] [--reduce] [--lines[=num]] [--edge-labels=files] [-T ps] [patch]"); + return 1; + } + + std::string_view selected_patch; + if (!opt_all) { + if (q.applied.empty()) { + if (!q.series_file_exists) { + err_line("No series file found"); + } else if (q.series.empty()) { + err_line("No patches in series"); + } else { + err_line("No patches applied"); + } + return 1; + } + + selected_patch = patch_arg.empty() ? std::string_view(q.applied.back()) : patch_arg; + if (!q.find_in_series(selected_patch).has_value()) { + err("Patch "); err(selected_patch); err_line(" is not in series"); + return 1; + } + if (!q.is_applied(selected_patch)) { + err("Patch "); err(selected_patch); err_line(" is not applied"); + return 1; + } + } else if (q.applied.empty()) { + err_line("No patches applied"); + return 1; + } + + std::vector nodes; + nodes.reserve(checked_cast(std::ssize(q.applied))); + for (ptrdiff_t i = 0; i < std::ssize(q.applied); ++i) { + const std::string &patch = q.applied[checked_cast(i)]; + auto files = files_in_patch(q, patch); + std::ranges::sort(files); + + GraphNode node; + node.number = checked_cast(i); + node.name = patch; + for (const auto &file : files) { + node.files.emplace(file, LineRanges{}); + } + nodes.push_back(std::move(node)); + } + + std::set used_nodes; + if (!selected_patch.empty()) { + auto selected = std::ranges::find_if(nodes, + [&](const GraphNode &node) { + return node.name == selected_patch; + }); + if (selected == nodes.end()) { + err("Patch "); err(selected_patch); err_line(" is not applied"); + return 1; + } + + selected->attrs.push_back("style=bold"); + selected->attrs.push_back("color=grey"); + + std::set selected_files; + for (const auto &[file, ranges] : selected->files) { + (void)ranges; + selected_files.insert(file); + } + for (auto &node : nodes) { + for (auto it = node.files.begin(); it != node.files.end(); ) { + if (!selected_files.contains(it->first)) { + it = node.files.erase(it); + } else { + ++it; + } + } + } + } + + std::map> files_seen; + std::map edges; + for (auto &node : nodes) { + for (const auto &[file, ranges] : node.files) { + (void)ranges; + auto seen_it = files_seen.find(file); + if (seen_it != files_seen.end()) { + std::optional dependency; + if (opt_lines.has_value()) { + for (auto prev = seen_it->second.rbegin(); + prev != seen_it->second.rend(); + ++prev) { + if (is_conflict(q, nodes, node.number, *prev, file, *opt_lines)) { + dependency = *prev; + break; + } + } + } else { + dependency = seen_it->second.back(); + } + + if (dependency.has_value()) { + add_edge(edges, *dependency, node.number, file); + used_nodes.insert(*dependency); + used_nodes.insert(node.number); + } + } + files_seen[file].push_back(node.number); + } + } + + if (!selected_patch.empty()) { + int selected_index = -1; + for (const auto &node : nodes) { + if (node.name == selected_patch) { + selected_index = node.number; + break; + } + } + + std::set reachable = collect_reachable(edges, selected_index, true); + std::set reverse = collect_reachable(edges, selected_index, false); + reachable.insert(reverse.begin(), reverse.end()); + + for (auto it = edges.begin(); it != edges.end(); ) { + if (!reachable.contains(it->first.first) || + !reachable.contains(it->first.second)) { + it = edges.erase(it); + } else { + ++it; + } + } + used_nodes = std::move(reachable); + } + + std::string dot = render_dot(nodes, edges, used_nodes, opt_reduce, opt_edge_labels); + out(dot); + return 0; +} + +// === src/cmd_mail.cpp === + +// This is free and unencumbered software released into the public domain. + +#include + + + +static std::string extract_diff(std::string_view content) { + auto lines = split_lines(content); + std::string diff; + bool in_diff = false; + for (const auto &line : lines) { + if (!in_diff) { + if (line.starts_with("Index:") || + line.starts_with("--- ") || + line.starts_with("diff ") || + line.starts_with("===")) { + in_diff = true; + } + } + if (in_diff) { + diff += line; + diff += '\n'; + } + } + return diff; +} + +static bool has_non_ascii(std::string_view s) { + for (char ch : s) { + if (static_cast(ch) > 127) return true; + } + return false; +} + +// RFC 2047 quoted-printable encoding for a header value +static std::string rfc2047_encode(std::string_view s) { + // Encode as =?UTF-8?q?...?= + // Characters that must be encoded: non-ASCII, =, ?, _, space + std::string result = "=?UTF-8?q?"; + ptrdiff_t line_len = 10; // length of "=?UTF-8?q?" + for (char ch : s) { + auto c = static_cast(ch); + std::string encoded; + if (c == '=' || c == '?' || c == '_' || c == ' ' || c > 127) { + static constexpr char hex[] = "0123456789ABCDEF"; + encoded = {'=', hex[c >> 4], hex[c & 0xf]}; + } else { + encoded = std::string(1, ch); + } + // Line wrap: if adding this would exceed ~75 chars, close and start new encoded word + if (line_len + std::ssize(encoded) + 2 > 75) { // 2 for "?=" + result += "?=\n =?UTF-8?q?"; + line_len = 12; // " =?UTF-8?q?" + } + result += encoded; + line_len += std::ssize(encoded); + } + result += "?="; + return result; +} + +// Format RFC 2822 date +static std::string format_rfc2822_date(int64_t t) { + DateTime dt = local_time(t); + + int tz_hours = dt.utc_offset / 3600; + int tz_mins = (dt.utc_offset % 3600) / 60; + if (tz_mins < 0) tz_mins = -tz_mins; + + static constexpr const char *days[] = + {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}; + static constexpr const char *months[] = + {"Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}; + + return std::format("{}, {} {} {} {:02d}:{:02d}:{:02d} {:+03d}{:02d}", + days[dt.weekday], + dt.day, + months[dt.month - 1], + dt.year, + dt.hour, dt.min, dt.sec, + tz_hours, tz_mins); +} + +// FNV-1a 64-bit hash +static uint64_t fnv1a_64(std::string_view data) { + uint64_t h = 0xcbf29ce484222325ULL; + for (char ch : data) + h = (h ^ static_cast(static_cast(ch))) * 0x100000001b3ULL; + return h; +} + +// Generate a Message-ID +static std::string make_message_id(int64_t t, int seq, + std::string_view from, + std::string_view content) { + // Extract domain from the from address + std::string domain = "localhost"; + auto at = str_find(from, '@'); + if (at >= 0) { + auto end = str_find(from, '>', at); + if (end < 0) end = std::ssize(from); + domain = std::string(from.substr(checked_cast(at + 1), checked_cast(end - at - 1))); + } + + uint64_t h = fnv1a_64(content); + return std::format("<{}.{}.{:016x}.{}@{}>", t, seq, h, current_time(), domain); +} + +// Compute the width needed for zero-padded patch numbers +static int num_width(int n) { + if (n < 10) return 1; + if (n < 100) return 2; + if (n < 1000) return 3; + return 4; +} + +int cmd_mail(QuiltState &q, int argc, char **argv) { + std::string mbox_file; + std::string from_addr; + std::string sender_addr; + std::string prefix = "PATCH"; + std::vector to_addrs; + std::vector cc_addrs; + std::vector bcc_addrs; + std::vector positional; + + for (int i = 1; i < argc; ++i) { + std::string_view arg = argv[i]; + if (arg == "--mbox" && i + 1 < argc) { + mbox_file = argv[++i]; + } else if (arg == "--send") { + err_line("quilt mail: send mode is not supported; use --mbox"); + return 1; + } else if (arg == "--sender" && i + 1 < argc) { + sender_addr = argv[++i]; + } else if (arg == "--from" && i + 1 < argc) { + from_addr = argv[++i]; + } else if (arg == "--prefix" && i + 1 < argc) { + prefix = argv[++i]; + } else if (arg == "--to" && i + 1 < argc) { + to_addrs.emplace_back(argv[++i]); + } else if (arg == "--cc" && i + 1 < argc) { + cc_addrs.emplace_back(argv[++i]); + } else if (arg == "--bcc" && i + 1 < argc) { + bcc_addrs.emplace_back(argv[++i]); + } else if (arg == "--subject" && i + 1 < argc) { + ++i; // consume and ignore (cover letter not generated) + } else if (arg == "-m" && i + 1 < argc) { + ++i; // consume and ignore (cover letter not generated) + } else if (arg == "-M" && i + 1 < argc) { + ++i; // consume and ignore (cover letter not generated) + } else if (arg == "--reply-to" && i + 1 < argc) { + ++i; // consume and ignore (cover letter not generated) + } else if (arg == "--charset" && i + 1 < argc) { + ++i; // consume and ignore + } else if (arg == "--signature" && i + 1 < argc) { + ++i; // consume and ignore + } else if (arg == "-h" || arg == "--help") { + out_line("Usage: quilt mail {--mbox file} [--prefix prefix] " + "[--sender ...] [--from ...] [--to ...] [--cc ...] " + "[--bcc ...] [first_patch [last_patch]]"); + return 0; + } else if (arg[0] != '-' || arg == "-") { + positional.emplace_back(arg); + } else { + err("quilt mail: unknown option: "); + err_line(arg); + return 1; + } + } + + if (mbox_file.empty()) { + err_line("quilt mail: --mbox is required"); + return 1; + } + + // Determine From address + std::string effective_from = from_addr; + if (effective_from.empty()) { + effective_from = sender_addr; + } + if (effective_from.empty()) { + err_line("quilt mail: --from or --sender is required"); + return 1; + } + + if (q.series.empty()) { + err_line("No patches in series"); + return 1; + } + + // Resolve patch range + ptrdiff_t first_idx = 0; + ptrdiff_t last_idx = std::ssize(q.series) - 1; + + if (std::ssize(positional) == 1) { + // Single patch + std::string name = positional[0]; + if (name == "-") { + // "-" as single arg means all patches + } else { + auto idx = q.find_in_series(name); + if (!idx) { + err_line("Patch " + name + " is not in series"); + return 1; + } + first_idx = *idx; + last_idx = *idx; + } + } else if (std::ssize(positional) == 2) { + std::string first_name = positional[0]; + std::string last_name = positional[1]; + + if (first_name == "-") { + first_idx = 0; + } else { + auto idx = q.find_in_series(first_name); + if (!idx) { + err_line("Patch " + first_name + " is not in series"); + return 1; + } + first_idx = *idx; + } + + if (last_name == "-") { + last_idx = std::ssize(q.series) - 1; + } else { + auto idx = q.find_in_series(last_name); + if (!idx) { + err_line("Patch " + last_name + " is not in series"); + return 1; + } + last_idx = *idx; + } + + if (first_idx > last_idx) { + err_line("quilt mail: first patch must come before last patch in series"); + return 1; + } + } else if (std::ssize(positional) > 2) { + err_line("Usage: quilt mail {--mbox file} [options] [first_patch [last_patch]]"); + return 1; + } + + ptrdiff_t total = last_idx - first_idx + 1; + int width = num_width(checked_cast(total)); + + std::string mbox; + + for (ptrdiff_t i = first_idx; i <= last_idx; ++i) { + const std::string &patch = q.series[checked_cast(i)]; + std::string patch_file = path_join(q.work_dir, q.patches_dir, patch); + std::string content = read_file(patch_file); + + if (content.empty()) { + err("Warning: patch "); + err(patch); + err_line(" is empty, skipping"); + continue; + } + + // Extract header and diff + std::string header = extract_header(content); + std::string diff = extract_diff(content); + + // Split header into subject (first line) and body (rest) + std::string subject_text; + std::string body; + if (!header.empty()) { + auto hdr_lines = split_lines(header); + // Find first non-empty line for subject + ptrdiff_t subj_line = 0; + while (subj_line < std::ssize(hdr_lines) && trim(hdr_lines[checked_cast(subj_line)]).empty()) { + subj_line++; + } + if (subj_line < std::ssize(hdr_lines)) { + subject_text = trim(hdr_lines[checked_cast(subj_line)]); + // Remaining lines become body + for (ptrdiff_t j = subj_line + 1; j < std::ssize(hdr_lines); ++j) { + body += hdr_lines[checked_cast(j)]; + body += '\n'; + } + } + } + + // If no header at all, use patch name as subject + if (subject_text.empty()) { + subject_text = patch; + } + + // Build Subject with prefix + std::string subject_prefix; + if (total == 1) { + subject_prefix = "[" + prefix + "]"; + } else { + int seq = checked_cast(i - first_idx + 1); + subject_prefix = std::format("[{} {:0{}d}/{}]", + prefix, seq, width, checked_cast(total)); + } + + std::string full_subject = subject_prefix + " " + subject_text; + + // RFC 2047 encode subject if needed + std::string subject_header; + if (has_non_ascii(full_subject)) { + subject_header = "Subject: " + rfc2047_encode(full_subject); + } else { + subject_header = "Subject: " + full_subject; + } + + int64_t msg_time = file_mtime(patch_file); + if (msg_time == -1) { + msg_time = current_time(); + } + int seq = checked_cast(i - first_idx + 1); + + // Build message + std::string msg; + + // Mbox separator + msg += "From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001\n"; + + // From header + msg += "From: " + effective_from + "\n"; + + // Date header + msg += "Date: " + format_rfc2822_date(msg_time) + "\n"; + + // Subject header + msg += subject_header + "\n"; + + // Message-ID + msg += "Message-ID: " + make_message_id(msg_time, seq, effective_from, content) + "\n"; + + // MIME headers if non-ASCII in body + if (has_non_ascii(header) || has_non_ascii(diff) || has_non_ascii(full_subject)) { + msg += "MIME-Version: 1.0\n"; + msg += "Content-Type: text/plain; charset=UTF-8\n"; + msg += "Content-Transfer-Encoding: 8bit\n"; + } + + // Optional To/Cc/Bcc + for (const auto &addr : to_addrs) { + msg += "To: " + addr + "\n"; + } + for (const auto &addr : cc_addrs) { + msg += "Cc: " + addr + "\n"; + } + for (const auto &addr : bcc_addrs) { + msg += "Bcc: " + addr + "\n"; + } + + // Blank line separating headers from body + msg += "\n"; + + // Body (remaining header text) + if (!body.empty()) { + // Trim leading blank lines from body + std::string_view bv = body; + while (bv.starts_with("\n")) { + bv = bv.substr(1); + } + if (!bv.empty()) { + msg += bv; + if (bv.back() != '\n') { + msg += '\n'; + } + } + } + + // Blank line between body and diff (when no diffstat separator) + if (!msg.empty() && msg.back() == '\n' && + (msg.size() < 2 || msg[msg.size() - 2] != '\n')) { + msg += '\n'; + } + + // Diff content + if (!diff.empty()) { + msg += diff; + if (diff.back() != '\n') { + msg += '\n'; + } + } + + // Trailer (like git's "-- \n2.53.0\n") + msg += "-- \nquilt\n\n"; + + mbox += msg; + } + + if (!write_file(mbox_file, mbox)) { + err_line("Failed to write mbox file: " + mbox_file); + return 1; + } + + out("Wrote "); + out(std::to_string(total)); + out(" patch"); + if (total != 1) out("es"); + out(" to "); + out_line(mbox_file); + + return 0; +} + +// === src/cmd_stubs.cpp === + +// This is free and unencumbered software released into the public domain. + +static int not_implemented(const char *name) +{ + err("quilt "); + err(name); + err_line(": not implemented"); + return 1; +} + +int cmd_grep(QuiltState &, int, char **) { return not_implemented("grep"); } +int cmd_setup(QuiltState &, int, char **) { return not_implemented("setup"); } +int cmd_shell(QuiltState &, int, char **) { return not_implemented("shell"); } + +// === src/platform_win32.cpp === + +// This is free and unencumbered software released into the public domain. +// +// Win32 platform implementation for quilt. +// Provides main entry point, UTF-16 <-> UTF-8 conversion at system +// boundaries, and Win32 implementations of the platform interface. +// +// This file is compiled only on Windows. POSIX builds use +// platform_posix.cpp instead. No #ifdef guards -- the build system +// selects exactly one platform source. + + +#ifndef _WIN32 +#error "platform_win32.cpp must only be compiled on Windows" +#endif + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#include + +#include +#include +#include + +static std::wstring utf8_to_wide(std::string_view s) +{ + if (s.empty()) return {}; + int len = MultiByteToWideChar(CP_UTF8, 0, s.data(), + checked_cast(std::ssize(s)), nullptr, 0); + if (len <= 0) return {}; + std::wstring out(len, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, s.data(), checked_cast(std::ssize(s)), + out.data(), len); + return out; +} + +static std::string wide_to_utf8(const wchar_t *w, int wlen = -1) +{ + if (!w) return {}; + if (wlen < 0) wlen = checked_cast(static_cast(wcslen(w))); + if (wlen == 0) return {}; + int len = WideCharToMultiByte(CP_UTF8, 0, w, wlen, + nullptr, 0, nullptr, nullptr); + if (len <= 0) return {}; + std::string out(len, '\0'); + WideCharToMultiByte(CP_UTF8, 0, w, wlen, + out.data(), len, nullptr, nullptr); + return out; +} + +static std::string read_handle(HANDLE h) +{ + std::string result; + char buf[4096]; + for (;;) { + DWORD n = 0; + if (!ReadFile(h, buf, sizeof(buf), &n, nullptr) || n == 0) + break; + result.append(buf, n); + } + return result; +} + +static bool write_handle(HANDLE h, const void *data, size_t len) +{ + const char *p = static_cast(data); + while (len > 0) { + DWORD written = 0; + if (!WriteFile(h, p, (DWORD)len, &written, nullptr)) + return false; + p += written; + len -= written; + } + return true; +} + +// Write a UTF-8 string to a handle. When the handle is a console, +// convert to UTF-16 and use WriteConsoleW so that non-ASCII text +// displays correctly. For pipes/files, write raw UTF-8 bytes. +static bool write_console_or_file(HANDLE h, std::string_view s) +{ + DWORD mode; + if (GetConsoleMode(h, &mode)) { + std::wstring wide = utf8_to_wide(s); + const wchar_t *p = wide.data(); + DWORD remaining = checked_cast(std::ssize(wide)); + while (remaining > 0) { + DWORD written = 0; + if (!WriteConsoleW(h, p, remaining, &written, nullptr)) + return false; + p += written; + remaining -= written; + } + return true; + } + return write_handle(h, s.data(), s.size()); +} + +// Create an inheritable pipe. read_end and write_end are set. +// inherit_which: 0 = read end inheritable, 1 = write end inheritable +static bool create_pipe(HANDLE &read_end, HANDLE &write_end, + int inherit_which) +{ + SECURITY_ATTRIBUTES sa{}; + sa.nLength = sizeof(sa); + sa.bInheritHandle = TRUE; + + if (!CreatePipe(&read_end, &write_end, &sa, 0)) + return false; + + // Make the non-inherited end non-inheritable + HANDLE &non_inherit = (inherit_which == 0) ? write_end : read_end; + SetHandleInformation(non_inherit, HANDLE_FLAG_INHERIT, 0); + return true; +} + +static std::wstring build_cmdline(const std::vector &argv) +{ + // Windows command-line quoting: wrap each arg in quotes, escape + // internal quotes and backslashes before quotes. + std::wstring cmdline; + for (ptrdiff_t i = 0; i < std::ssize(argv); ++i) { + if (i > 0) cmdline += L' '; + + std::wstring arg = utf8_to_wide(argv[checked_cast(i)]); + + // Check if quoting is needed + bool needs_quote = arg.empty(); + for (wchar_t c : arg) { + if (c == L' ' || c == L'\t' || c == L'"') { + needs_quote = true; + break; + } + } + + if (!needs_quote) { + cmdline += arg; + continue; + } + + cmdline += L'"'; + int num_backslashes = 0; + for (wchar_t c : arg) { + if (c == L'\\') { + ++num_backslashes; + } else if (c == L'"') { + // Escape preceding backslashes and the quote + for (int j = 0; j < num_backslashes; ++j) + cmdline += L'\\'; + cmdline += L'\\'; + cmdline += L'"'; + num_backslashes = 0; + } else { + num_backslashes = 0; + cmdline += c; + } + } + // Escape trailing backslashes before closing quote + for (int j = 0; j < num_backslashes; ++j) + cmdline += L'\\'; + cmdline += L'"'; + } + return cmdline; +} + +static ProcessResult run_cmd_impl(const std::vector &argv, + const char *stdin_data, size_t stdin_len) +{ + ProcessResult result{}; + + if (argv.empty()) { + result.exit_code = -1; + return result; + } + + // Create pipes for stdout, stderr, and optionally stdin + HANDLE stdout_rd = INVALID_HANDLE_VALUE, stdout_wr = INVALID_HANDLE_VALUE; + HANDLE stderr_rd = INVALID_HANDLE_VALUE, stderr_wr = INVALID_HANDLE_VALUE; + HANDLE stdin_rd = INVALID_HANDLE_VALUE, stdin_wr = INVALID_HANDLE_VALUE; + + if (!create_pipe(stdout_rd, stdout_wr, 1)) { // write end inheritable + result.exit_code = -1; + return result; + } + if (!create_pipe(stderr_rd, stderr_wr, 1)) { + CloseHandle(stdout_rd); CloseHandle(stdout_wr); + result.exit_code = -1; + return result; + } + + bool need_stdin = (stdin_data != nullptr); + if (need_stdin) { + if (!create_pipe(stdin_rd, stdin_wr, 0)) { // read end inheritable + CloseHandle(stdout_rd); CloseHandle(stdout_wr); + CloseHandle(stderr_rd); CloseHandle(stderr_wr); + result.exit_code = -1; + return result; + } + } + + STARTUPINFOW si{}; + si.cb = sizeof(si); + si.dwFlags = STARTF_USESTDHANDLES; + si.hStdOutput = stdout_wr; + si.hStdError = stderr_wr; + si.hStdInput = need_stdin ? stdin_rd : GetStdHandle(STD_INPUT_HANDLE); + + PROCESS_INFORMATION pi{}; + + std::wstring cmdline = build_cmdline(argv); + + BOOL ok = CreateProcessW( + nullptr, // lpApplicationName + cmdline.data(), // lpCommandLine (mutable) + nullptr, nullptr, // process/thread security + TRUE, // inherit handles + CREATE_NO_WINDOW, // creation flags + nullptr, // environment + nullptr, // current directory + &si, &pi + ); + + // Close child-side handles in parent + CloseHandle(stdout_wr); + CloseHandle(stderr_wr); + if (need_stdin) CloseHandle(stdin_rd); + + if (!ok) { + result.exit_code = -1; + result.err = "CreateProcessW failed: " + std::to_string(GetLastError()); + CloseHandle(stdout_rd); + CloseHandle(stderr_rd); + if (need_stdin) CloseHandle(stdin_wr); + return result; + } + + // Write stdin data + if (need_stdin) { + write_handle(stdin_wr, stdin_data, stdin_len); + CloseHandle(stdin_wr); + } + + // Read stdout and stderr + result.out = read_handle(stdout_rd); + result.err = read_handle(stderr_rd); + CloseHandle(stdout_rd); + CloseHandle(stderr_rd); + + // Wait for process + WaitForSingleObject(pi.hProcess, INFINITE); + + DWORD exit_code = 0; + GetExitCodeProcess(pi.hProcess, &exit_code); + result.exit_code = static_cast(exit_code); + + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); + + return result; +} + +ProcessResult run_cmd(const std::vector &argv) +{ + return run_cmd_impl(argv, nullptr, 0); +} + +ProcessResult run_cmd_input(const std::vector &argv, + std::string_view stdin_data) +{ + return run_cmd_impl(argv, stdin_data.data(), stdin_data.size()); +} + +int run_cmd_tty(const std::vector &argv) +{ + if (argv.empty()) return -1; + + STARTUPINFOW si{}; + si.cb = sizeof(si); + // No STARTF_USESTDHANDLES — child inherits console + + PROCESS_INFORMATION pi{}; + std::wstring cmdline = build_cmdline(argv); + + BOOL ok = CreateProcessW( + nullptr, cmdline.data(), + nullptr, nullptr, + FALSE, 0, + nullptr, nullptr, + &si, &pi + ); + if (!ok) return -1; + + WaitForSingleObject(pi.hProcess, INFINITE); + DWORD exit_code = 0; + GetExitCodeProcess(pi.hProcess, &exit_code); + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); + return static_cast(exit_code); +} + +std::string read_file(std::string_view path) +{ + std::wstring wpath = utf8_to_wide(path); + HANDLE h = CreateFileW(wpath.c_str(), GENERIC_READ, FILE_SHARE_READ, + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, + nullptr); + if (h == INVALID_HANDLE_VALUE) return {}; + + std::string result = read_handle(h); + CloseHandle(h); + return result; +} + +bool write_file(std::string_view path, std::string_view content) +{ + std::wstring wpath = utf8_to_wide(path); + HANDLE h = CreateFileW(wpath.c_str(), GENERIC_WRITE, 0, + nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, + nullptr); + if (h == INVALID_HANDLE_VALUE) return false; + + bool ok = write_handle(h, content.data(), content.size()); + CloseHandle(h); + return ok; +} + +bool append_file(std::string_view path, std::string_view content) +{ + std::wstring wpath = utf8_to_wide(path); + HANDLE h = CreateFileW(wpath.c_str(), FILE_APPEND_DATA, 0, + nullptr, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, + nullptr); + if (h == INVALID_HANDLE_VALUE) return false; + + bool ok = write_handle(h, content.data(), content.size()); + CloseHandle(h); + return ok; +} + +bool copy_file(std::string_view src, std::string_view dst) +{ + std::wstring wsrc = utf8_to_wide(src); + std::wstring wdst = utf8_to_wide(dst); + return CopyFileW(wsrc.c_str(), wdst.c_str(), FALSE) != 0; +} + +bool rename_path(std::string_view old_path, std::string_view new_path) +{ + std::wstring wold = utf8_to_wide(old_path); + std::wstring wnew = utf8_to_wide(new_path); + return MoveFileExW(wold.c_str(), wnew.c_str(), + MOVEFILE_REPLACE_EXISTING) != 0; +} + +bool delete_file(std::string_view path) +{ + std::wstring wpath = utf8_to_wide(path); + return DeleteFileW(wpath.c_str()) != 0; +} + +bool delete_dir_recursive(std::string_view path) +{ + std::wstring wpath = utf8_to_wide(path); + std::wstring pattern = wpath + L"\\*"; + + WIN32_FIND_DATAW fd; + HANDLE hFind = FindFirstFileW(pattern.c_str(), &fd); + if (hFind == INVALID_HANDLE_VALUE) return false; + + bool ok = true; + do { + if (wcscmp(fd.cFileName, L".") == 0 || + wcscmp(fd.cFileName, L"..") == 0) + continue; + + std::wstring child = wpath + L"\\" + fd.cFileName; + std::string child_utf8 = wide_to_utf8(child.c_str(), + checked_cast(std::ssize(child))); + + if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) { + if (!delete_dir_recursive(child_utf8)) ok = false; + } else { + if (!DeleteFileW(child.c_str())) ok = false; + } + } while (FindNextFileW(hFind, &fd)); + FindClose(hFind); + + if (!RemoveDirectoryW(wpath.c_str())) ok = false; + return ok; +} + +bool make_dir(std::string_view path) +{ + std::wstring wpath = utf8_to_wide(path); + if (CreateDirectoryW(wpath.c_str(), nullptr)) return true; + return GetLastError() == ERROR_ALREADY_EXISTS; +} + +bool make_dirs(std::string_view path) +{ + if (path.empty()) return false; + + std::string p(path); + // Normalize separators + for (char &c : p) { + if (c == '/') c = '\\'; + } + + ptrdiff_t start = 1; + if (std::ssize(p) >= 3 && std::isalpha(static_cast(p[0])) && + p[1] == ':' && p[2] == '\\') { + start = 3; + } else if (std::ssize(p) >= 2 && p[0] == '\\' && p[1] == '\\') { + // Leave UNC handling to the normal loop after the \\server\share prefix. + ptrdiff_t slash_count = 0; + start = 2; + for (ptrdiff_t i = 2; i < std::ssize(p); ++i) { + if (p[checked_cast(i)] == '\\') { + ++slash_count; + if (slash_count == 2) { + start = i + 1; + break; + } + } + } + } + + // Walk through path components, creating each. + for (ptrdiff_t i = start; i < std::ssize(p); ++i) { + if (p[checked_cast(i)] == '\\') { + p[checked_cast(i)] = '\0'; + std::wstring wp = utf8_to_wide(p.c_str()); + if (!CreateDirectoryW(wp.c_str(), nullptr)) { + if (GetLastError() != ERROR_ALREADY_EXISTS) + return false; + } + p[checked_cast(i)] = '\\'; + } + } + std::wstring wp = utf8_to_wide(p); + if (!CreateDirectoryW(wp.c_str(), nullptr)) { + if (GetLastError() != ERROR_ALREADY_EXISTS) + return false; + } + return true; +} + +bool file_exists(std::string_view path) +{ + std::wstring wpath = utf8_to_wide(path); + DWORD attr = GetFileAttributesW(wpath.c_str()); + return attr != INVALID_FILE_ATTRIBUTES; +} + +bool is_directory(std::string_view path) +{ + std::wstring wpath = utf8_to_wide(path); + DWORD attr = GetFileAttributesW(wpath.c_str()); + if (attr == INVALID_FILE_ATTRIBUTES) return false; + return (attr & FILE_ATTRIBUTE_DIRECTORY) != 0; +} + +int64_t file_mtime(std::string_view path) +{ + std::wstring wpath = utf8_to_wide(path); + WIN32_FILE_ATTRIBUTE_DATA data; + if (!GetFileAttributesExW(wpath.c_str(), GetFileExInfoStandard, &data)) + return -1; + // FILETIME: 100-nanosecond intervals since 1601-01-01 + uint64_t ft = (static_cast(data.ftLastWriteTime.dwHighDateTime) << 32) + | data.ftLastWriteTime.dwLowDateTime; + return static_cast((ft - 116444736000000000ULL) / 10000000ULL); +} + +std::vector list_dir(std::string_view path) +{ + std::vector entries; + std::wstring wpath = utf8_to_wide(path); + std::wstring pattern = wpath + L"\\*"; + + WIN32_FIND_DATAW fd; + HANDLE hFind = FindFirstFileW(pattern.c_str(), &fd); + if (hFind == INVALID_HANDLE_VALUE) return entries; + + do { + if (wcscmp(fd.cFileName, L".") == 0 || + wcscmp(fd.cFileName, L"..") == 0) + continue; + + DirEntry e; + e.name = wide_to_utf8(fd.cFileName); + e.is_dir = (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0; + entries.push_back(std::move(e)); + } while (FindNextFileW(hFind, &fd)); + FindClose(hFind); + + return entries; +} + +static void find_files_impl(const std::wstring &base, + const std::string &prefix, + std::vector &out) +{ + std::wstring pattern = base + L"\\*"; + WIN32_FIND_DATAW fd; + HANDLE hFind = FindFirstFileW(pattern.c_str(), &fd); + if (hFind == INVALID_HANDLE_VALUE) return; + + do { + if (wcscmp(fd.cFileName, L".") == 0 || + wcscmp(fd.cFileName, L"..") == 0) + continue; + + std::string name = wide_to_utf8(fd.cFileName); + std::wstring full = base + L"\\" + fd.cFileName; + std::string rel = prefix.empty() ? name : prefix + "/" + name; + + if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) { + find_files_impl(full, rel, out); + } else { + out.push_back(rel); + } + } while (FindNextFileW(hFind, &fd)); + FindClose(hFind); +} + +std::vector find_files_recursive(std::string_view dir) +{ + std::vector result; + std::wstring wdir = utf8_to_wide(dir); + find_files_impl(wdir, "", result); + return result; +} + +std::string make_temp_dir() +{ + wchar_t tmp_path[MAX_PATH + 1]; + if (!GetTempPathW(MAX_PATH + 1, tmp_path)) return {}; + + DWORD pid = GetCurrentProcessId(); + static unsigned counter = 0; + wchar_t tmp_dir[MAX_PATH + 1]; + for (int attempt = 0; attempt < 100; ++attempt) { + unsigned n = counter++; + swprintf(tmp_dir, MAX_PATH, L"%sqlt%lu_%u", tmp_path, (unsigned long)pid, n); + if (CreateDirectoryW(tmp_dir, nullptr)) { + std::string result = wide_to_utf8(tmp_dir); + for (char &c : result) + if (c == '\\') c = '/'; + return result; + } + } + return {}; +} + +std::string get_env(std::string_view name) +{ + std::wstring wname = utf8_to_wide(name); + // First call to get required buffer size + DWORD len = GetEnvironmentVariableW(wname.c_str(), nullptr, 0); + if (len == 0) return {}; + std::wstring buf(len, L'\0'); + GetEnvironmentVariableW(wname.c_str(), buf.data(), len); + // Remove trailing null + if (!buf.empty() && buf.back() == L'\0') buf.pop_back(); + return wide_to_utf8(buf.c_str(), checked_cast(std::ssize(buf))); +} + +void set_env(std::string_view name, std::string_view value) +{ + std::wstring wname = utf8_to_wide(name); + std::wstring wvalue = utf8_to_wide(value); + SetEnvironmentVariableW(wname.c_str(), wvalue.c_str()); +} + +std::string get_home_dir() +{ + std::string home = get_env("HOME"); + if (!home.empty()) return home; + std::string up = get_env("USERPROFILE"); + if (!up.empty()) return up; + std::string hd = get_env("HOMEDRIVE"); + std::string hp = get_env("HOMEPATH"); + if (!hd.empty() && !hp.empty()) return hd + hp; + return {}; +} + +std::string get_system_quiltrc() +{ + wchar_t buf[MAX_PATH]; + DWORD len = GetModuleFileNameW(NULL, buf, MAX_PATH); + if (len == 0 || len >= MAX_PATH) return {}; + std::wstring path(buf, len); + // Strip exe filename + auto pos = path.rfind(L'\\'); + if (pos == std::wstring::npos) return {}; + path.resize(pos); + // Strip parent directory (e.g. bin/) + pos = path.rfind(L'\\'); + if (pos == std::wstring::npos) return {}; + path.resize(pos); + path += L"\\etc\\quilt.quiltrc"; + std::string result = wide_to_utf8(path.c_str(), + checked_cast(std::ssize(path))); + for (char &c : result) { + if (c == '\\') c = '/'; + } + return result; +} + +std::string get_cwd() +{ + DWORD len = GetCurrentDirectoryW(0, nullptr); + if (len == 0) return {}; + std::wstring buf(len, L'\0'); + GetCurrentDirectoryW(len, buf.data()); + // Remove trailing null + if (!buf.empty() && buf.back() == L'\0') buf.pop_back(); + std::string result = wide_to_utf8(buf.c_str(), checked_cast(std::ssize(buf))); + // Normalize backslashes to forward slashes for consistency + for (char &c : result) { + if (c == '\\') c = '/'; + } + return result; +} + +bool set_cwd(std::string_view path) +{ + std::wstring wpath = utf8_to_wide(path); + return SetCurrentDirectoryW(wpath.c_str()) != 0; +} + +void fd_write_stdout(std::string_view s) +{ + HANDLE h = GetStdHandle(STD_OUTPUT_HANDLE); + if (h != INVALID_HANDLE_VALUE) + write_console_or_file(h, s); +} + +void fd_write_stderr(std::string_view s) +{ + HANDLE h = GetStdHandle(STD_ERROR_HANDLE); + if (h != INVALID_HANDLE_VALUE) + write_console_or_file(h, s); +} + +bool stdout_is_tty() +{ + HANDLE h = GetStdHandle(STD_OUTPUT_HANDLE); + return h != INVALID_HANDLE_VALUE && GetFileType(h) == FILE_TYPE_CHAR; +} + +std::string read_stdin() +{ + HANDLE h = GetStdHandle(STD_INPUT_HANDLE); + if (h == INVALID_HANDLE_VALUE) return {}; + return read_handle(h); +} + +int64_t current_time() +{ + return _time64(nullptr); +} + +DateTime local_time(int64_t timestamp) +{ + __time64_t t = static_cast<__time64_t>(timestamp); + struct tm local_tm, utc_tm; + _localtime64_s(&local_tm, &t); + _gmtime64_s(&utc_tm, &t); + + long local_sec = local_tm.tm_hour * 3600L + local_tm.tm_min * 60L + local_tm.tm_sec; + long utc_sec = utc_tm.tm_hour * 3600L + utc_tm.tm_min * 60L + utc_tm.tm_sec; + long diff = local_sec - utc_sec; + int day_diff = local_tm.tm_yday - utc_tm.tm_yday; + if (day_diff > 1) day_diff = -1; + if (day_diff < -1) day_diff = 1; + diff += day_diff * 86400L; + + return { + local_tm.tm_year + 1900, + local_tm.tm_mon + 1, + local_tm.tm_mday, + local_tm.tm_hour, + local_tm.tm_min, + local_tm.tm_sec, + local_tm.tm_wday, + static_cast(diff), + }; +} + +int main(int, char **) +{ + // Use GetCommandLineW + CommandLineToArgvW for reliable parsing + int argc = 0; + wchar_t **wargv = CommandLineToArgvW(GetCommandLineW(), &argc); + if (!wargv) return 1; + + // Convert to UTF-8 + std::vector args_storage; + args_storage.reserve(argc); + for (int i = 0; i < argc; ++i) + args_storage.push_back(wide_to_utf8(wargv[i])); + LocalFree(wargv); + + std::vector argv_ptrs; + argv_ptrs.reserve(argc + 1); + for (auto &a : args_storage) + argv_ptrs.push_back(a.data()); + argv_ptrs.push_back(nullptr); + + return quilt_main(argc, argv_ptrs.data()); +} + +