удалены TOOL.md и conv, инструкции переведены на convy
- TOOL.md был стартовой точкой разработки инструмента и свою задачу выполнил: решения о нём теперь живут в его собственном репозитории, открытые вопросы перенесены туда же - питоновский conv собран под прежнюю модель копий (зеркальное дерево, именованные регионы, origin_hash) и удалён вместе с ней - команды в README.md переписаны на convy, включая suite-сторону и sync
This commit is contained in:
@@ -9,8 +9,7 @@ code in this repository.
|
|||||||
|
|
||||||
Канон конвенций разработки для личных проектов. Сами конвенции лежат в
|
Канон конвенций разработки для личных проектов. Сами конвенции лежат в
|
||||||
`conventions/{arch,lang/<язык>,stack/<стек>}/`; обвязка канона (`README.md`,
|
`conventions/{arch,lang/<язык>,stack/<стек>}/`; обвязка канона (`README.md`,
|
||||||
`LANGUAGE.md`, `GUIDE.md`, `READING.md`, `.conventions-suite.toml`, `conv`)
|
`LANGUAGE.md`, `GUIDE.md`, `READING.md`, `.conventions-suite.toml`) живёт в
|
||||||
живёт в
|
|
||||||
корне. К потребителю из неё едет только `READING.md` — короткое описание языка
|
корне. К потребителю из неё едет только `READING.md` — короткое описание языка
|
||||||
для читателя копий.
|
для читателя копий.
|
||||||
|
|
||||||
@@ -209,14 +208,12 @@ META-38: ось объявлена в шапке ключами `lang:` и `stac
|
|||||||
|
|
||||||
## Состояние репозитория
|
## Состояние репозитория
|
||||||
|
|
||||||
- Тестов, линтеров и CI нет. `conv` — python3 CLI на одной stdlib; запускают
|
- Тестов, линтеров и CI здесь нет: репозиторий — данные, а не код. Проверяет
|
||||||
его из корня репозитория-потребителя (`~/projects/private/dev-conventions/`
|
их `convy suite check`, живущий в своём репозитории и ставящийся бинарём.
|
||||||
плюс команда).
|
- Модель копий, описанная в `README.md`, реализована в `convy`. Прежний
|
||||||
- Модель копий, описанная в `README.md`, согласована, но не реализована:
|
питоновский `conv` удалён вместе со своей моделью (зеркальное дерево,
|
||||||
`conv` собран под прежнюю (зеркальное дерево, именованные регионы,
|
именованные регионы, `origin_hash`). При расхождении обвязки с инструментом
|
||||||
`origin_hash`, команды `status`/`diff`/`push`). Сами конвенции к новой
|
истина — README, а не код.
|
||||||
модели приведены — регионов в каноне нет. При правке обвязки истина —
|
|
||||||
README, а не код `conv`.
|
|
||||||
- Ни один репозиторий-потребитель ещё не подключён: копий с шапкой `origin:`
|
- Ни один репозиторий-потребитель ещё не подключён: копий с шапкой `origin:`
|
||||||
в природе нет.
|
в природе нет.
|
||||||
- `TODO.md` — площадка для обсуждения на будущее, а не принятые решения; при
|
- `TODO.md` — площадка для обсуждения на будущее, а не принятые решения; при
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
| [GUIDE.md](GUIDE.md) | как ведут конвенции: когда заводить, механизация, отступления |
|
| [GUIDE.md](GUIDE.md) | как ведут конвенции: когда заводить, механизация, отступления |
|
||||||
| [READING.md](READING.md) | как читать конвенцию: то, что едет к потребителю |
|
| [READING.md](READING.md) | как читать конвенцию: то, что едет к потребителю |
|
||||||
| `.conventions-suite.toml` | манифест набора: язык, темы, префиксы правил |
|
| `.conventions-suite.toml` | манифест набора: язык, темы, префиксы правил |
|
||||||
| `conv` | сборка копий |
|
|
||||||
|
|
||||||
К потребителю едет содержимое `conventions/` и один файл обвязки —
|
К потребителю едет содержимое `conventions/` и один файл обвязки —
|
||||||
`READING.md`; остальная обвязка остаётся в каноне. Самодостаточность копии это
|
`READING.md`; остальная обвязка остаётся в каноне. Самодостаточность копии это
|
||||||
@@ -366,18 +365,30 @@ topics = ["client-logging"]
|
|||||||
|
|
||||||
## Команды
|
## Команды
|
||||||
|
|
||||||
|
Копии собирает `convy` — отдельный инструмент, живущий в своём репозитории и
|
||||||
|
ставящийся бинарём. Запускают его из корня репозитория-потребителя:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
conv list # какие темы есть в каноне и что подключено
|
convy init --source <ссылка на канон> --component backend \
|
||||||
conv add time # добавить тему в манифест и собрать файл
|
--dir docs/conventions --lang go
|
||||||
conv add time --for backend # то же, когда компонентов несколько
|
convy add time # подписаться на тему и собрать файл
|
||||||
conv pull # пересобрать всё, что перечислено в манифесте
|
convy add time --for backend # то же, когда компонентов несколько
|
||||||
# (и обновить READING.md рядом с копиями)
|
convy pull # пересобрать всё, что перечислено в манифесте
|
||||||
conv pull --for web # только один компонент
|
# (и обновить READING.md рядом с копиями)
|
||||||
|
convy pull --for web # только один компонент
|
||||||
|
convy sync # подвести раскладку файлов под манифест
|
||||||
|
convy list # что подключено и что ещё есть в каноне
|
||||||
|
convy check # проверить форму того, что лежит здесь
|
||||||
```
|
```
|
||||||
|
|
||||||
При одном компоненте `--for` не нужен. При нескольких команда без него не
|
При одном компоненте `--for` не нужен. При нескольких команда без него не
|
||||||
угадывает, а отказывает и перечисляет имена.
|
угадывает, а отказывает и перечисляет имена.
|
||||||
|
|
||||||
|
Манифест подключения правится и руками — это данные, а не текст с
|
||||||
|
комментариями. Что бы в нём ни поменяли, раскладку под него подводит `convy
|
||||||
|
sync`: чего не хватает — соберёт, что осиротело — уберёт, а копию с локальной
|
||||||
|
частью не тронет и назовёт.
|
||||||
|
|
||||||
Отчёт о том, что изменилось, отдельной командой не выдаётся: после `pull`
|
Отчёт о том, что изменилось, отдельной командой не выдаётся: после `pull`
|
||||||
его показывает `git diff`, а решение — принять, поправить или откатить —
|
его показывает `git diff`, а решение — принять, поправить или откатить —
|
||||||
принимает человек перед коммитом.
|
принимает человек перед коммитом.
|
||||||
@@ -386,11 +397,9 @@ conv pull --for web # только один компонент
|
|||||||
репозитории, переносится в канон руками: это редкая операция, и её цена —
|
репозитории, переносится в канон руками: это редкая операция, и её цена —
|
||||||
не аргумент против того, чтобы направление оставалось односторонним.
|
не аргумент против того, чтобы направление оставалось односторонним.
|
||||||
|
|
||||||
Запускать из корня репозитория:
|
Сам канон ведут те же командой под `suite`: `convy suite add` заводит
|
||||||
|
конвенцию, `convy suite rule` дописывает правило, `convy suite retire`
|
||||||
```bash
|
снимает, `convy suite check` проверяет целостность набора.
|
||||||
~/projects/private/dev-conventions/conv pull
|
|
||||||
```
|
|
||||||
|
|
||||||
Обёртка в раннере репозитория (`inv conventions -- pull` для ansible,
|
Обёртка в раннере репозитория (`inv conventions -- pull` для ansible,
|
||||||
`task conventions -- pull` для Go) — тонкий проброс аргументов, чтобы
|
`task conventions -- pull` для Go) — тонкий проброс аргументов, чтобы
|
||||||
@@ -410,11 +419,11 @@ conv pull --for web # только один компонент
|
|||||||
|
|
||||||
## Состояние
|
## Состояние
|
||||||
|
|
||||||
Модель выше — согласованная, а не реализованная. `conv` пока собран под
|
Модель выше реализована в `convy`: сборка копий, отбор слоёв по объявленной
|
||||||
прежнюю: зеркальное дерево копий вместо плоского, именованные регионы
|
оси, маркер локальной части, `READING.md` рядом с копиями, проверка
|
||||||
`<!-- local:имя -->` вместо одного маркера, `origin_hash` в шапке и команды
|
целостности набора. Прежний питоновский `conv` — с зеркальным деревом,
|
||||||
`status`, `diff`, `push`; `READING.md` рядом с копиями он тоже пока не
|
именованными регионами и `origin_hash` — удалён вместе со своей моделью.
|
||||||
кладёт, компонентов и объявленной оси не знает и выбирает слои по пути. Сами
|
|
||||||
конвенции уже приведены к новой модели — именованных регионов в каноне нет,
|
Ни один репозиторий-потребитель ещё не подключён: копий с шапкой `origin:` в
|
||||||
ось объявлена в шапках. Ни один репозиторий-потребитель не подключён, поэтому
|
природе нет. Пока это так, непроверенным остаётся главное — как всё это живёт
|
||||||
переход никого не ломает.
|
в чужом репозитории через полгода после первой сборки.
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
Черновик для следующего разговора: вопросы и варианты, а не принятые
|
Черновик для следующего разговора: вопросы и варианты, а не принятые
|
||||||
решения. Закрытый вопрос отсюда удаляется — принятое решение живёт в
|
решения. Закрытый вопрос отсюда удаляется — принятое решение живёт в
|
||||||
`README.md`, `GUIDE.md`, `LANGUAGE.md` или `TOOL.md`, а не в этом файле.
|
`README.md`, `GUIDE.md` или `LANGUAGE.md`, а не в этом файле.
|
||||||
|
|
||||||
Вопросы про инструмент здесь не живут — они собраны в `TOOL.md`, раздел
|
Вопросы про инструмент здесь не живут — они собраны в его собственном
|
||||||
«Открытые вопросы».
|
репозитории.
|
||||||
|
|
||||||
Две секции: сначала язык и подход, потом сам набор и подключение.
|
Две секции: сначала язык и подход, потом сам набор и подключение.
|
||||||
|
|
||||||
|
|||||||
@@ -1,205 +0,0 @@
|
|||||||
# Инструмент: convy
|
|
||||||
|
|
||||||
Стартовая точка для разработки. Здесь — принятые решения об инструменте и
|
|
||||||
открытые вопросы к нему; модель, которую он реализует, описана в
|
|
||||||
[README.md](README.md), а форма правил — в [LANGUAGE.md](LANGUAGE.md). При
|
|
||||||
расхождении истина там, а не здесь.
|
|
||||||
|
|
||||||
## Что это
|
|
||||||
|
|
||||||
`convy` — CLI для управления конвенциями: собирает копии в проектах и
|
|
||||||
проверяет целостность набора. Пишется на Go, живёт в отдельном репозитории,
|
|
||||||
ставится готовым бинарём.
|
|
||||||
|
|
||||||
Имя выбрано за то, что держит корень предметной области и звучит как маленький
|
|
||||||
помощник, а уменьшительное `-y` обещает малость — что правда: инструмент
|
|
||||||
копирует файлы, собирает тему из слоёв и проверяет форму, не более. Рядом
|
|
||||||
существует английское слово `convey`, отличающееся одной буквой и подходящее
|
|
||||||
по смыслу, — опечатку в текстах стоит проверять отдельно.
|
|
||||||
|
|
||||||
Отдельный репозиторий нужен по двум причинам. Инструмент и данные в одном
|
|
||||||
репозитории правятся одним движением, и это мешает: лежащий отдельно бинарь
|
|
||||||
физически не даёт починить инструмент «заодно» с правкой конвенции. Вторая —
|
|
||||||
независимый бинарь работает против любого набора и любого проекта, а не против
|
|
||||||
одного конкретного канона.
|
|
||||||
|
|
||||||
## Термины
|
|
||||||
|
|
||||||
| Уровень | Английский | Русский | Что там лежит |
|
|
||||||
|---|---|---|---|
|
|
||||||
| набор | `suite` | набор | `conventions/`, манифест набора, обвязка |
|
|
||||||
| проект | `project` | проект | манифест подключения, компоненты |
|
|
||||||
| компонент | `component` | компонент | директория копий: один язык, один стек, один вид приложения |
|
|
||||||
|
|
||||||
`package`, `bundle`, `library` не берём: они тащат багаж менеджеров
|
|
||||||
зависимостей — версии, разрешение, лок, — которого в модели нет. `set` не
|
|
||||||
годится в CLI: в позиции подкоманды читается глаголом.
|
|
||||||
|
|
||||||
Компонент — адресат сборки: подписка принадлежит проекту, а собранный файл
|
|
||||||
читает тот, кто правит конкретный код. Определение — область, где все
|
|
||||||
выбранные слои действуют одновременно (`sqlite` и `postgres` — да, go и
|
|
||||||
javascript — никогда). Уровнем CLI компонент не становится: это аргумент
|
|
||||||
`--for`, а не подкоманда.
|
|
||||||
|
|
||||||
«Канон» — имя этого конкретного набора, а не термин уровня; в общих
|
|
||||||
формулировках употребляется «набор». «Потребитель» — слово про роль
|
|
||||||
репозитория, а не про уровень.
|
|
||||||
|
|
||||||
Манифесты названы по тому, что описывают, а не по уровню ради симметрии:
|
|
||||||
`.conventions-suite.toml` в наборе — идентичность набора, `.conventions.toml`
|
|
||||||
в проекте — подключённые конвенции. Оба начинаются с точки, потому что оба —
|
|
||||||
данные инструмента, а не документы репозитория: они читаются и **переписываются
|
|
||||||
целиком** командами, комментариев не держат, и объяснения к ним живут в
|
|
||||||
соседних файлах. Отсюда же бесплатное определение контекста: по имени рядом
|
|
||||||
видно, где ты.
|
|
||||||
|
|
||||||
Прежде манифест набора назывался `suite.toml` и точки не имел — на том
|
|
||||||
основании, что правится он руками при каждой новой теме. Основание отпало:
|
|
||||||
правит его инструмент.
|
|
||||||
|
|
||||||
## Раскладка команд
|
|
||||||
|
|
||||||
Глубина команды отражает частоту и адресата. Проектные команды выполняются в
|
|
||||||
каждом репозитории и часто; ведение набора — в одном репозитории и редко.
|
|
||||||
|
|
||||||
```
|
|
||||||
convy add <тема> подписаться и собрать
|
|
||||||
convy pull пересобрать подписанное
|
|
||||||
convy list что подключено и что можно взять
|
|
||||||
convy check проверить форму того, что здесь
|
|
||||||
|
|
||||||
convy suite check целостность набора: префиксы, темы, оси, ссылки, форма
|
|
||||||
convy suite new новая тема: шапка, префикс, запись в манифест
|
|
||||||
```
|
|
||||||
|
|
||||||
Проектные команды принимают `--for <компонент>`. При одном компоненте флаг не
|
|
||||||
нужен; при нескольких команда без него отказывает и перечисляет имена — тот
|
|
||||||
же принцип, что и с контекстом: наугад не делается ничего. `convy list`
|
|
||||||
группирует вывод по компонентам.
|
|
||||||
|
|
||||||
Граница проходит не по «проектное против наборного», а по «частое и
|
|
||||||
повсеместное» против «только у автора». Поэтому `check` остаётся наверху:
|
|
||||||
форма правила одна и та же, локальные правила проекта на `X`-префиксах
|
|
||||||
написаны по ней же, и проверять их у себя нужно без подкоманды. Под `suite`
|
|
||||||
уходит то, что в проекте не имеет смысла.
|
|
||||||
|
|
||||||
Три следствия для реализации:
|
|
||||||
|
|
||||||
- **контекст определяется и отказ говорится явно.** `.conventions.toml` рядом
|
|
||||||
— проект, `.conventions-suite.toml` — набор. Проектная команда, набранная в
|
|
||||||
наборе, не делает ничего наугад: она отказывает и подсказывает наборный
|
|
||||||
аналог;
|
|
||||||
- **помощь группируется заголовками** «В проекте» и «В наборе»: в плоском
|
|
||||||
списке уровни не видны;
|
|
||||||
- **синонимов нет.** `convy project pull` рядом с `convy pull` не заводим: два
|
|
||||||
способа сказать одно — то, от чего модель избавлялась в остальных местах.
|
|
||||||
|
|
||||||
В проектах вызов идёт через обёртку раннера (`inv conventions -- pull`,
|
|
||||||
`task conventions -- pull`), которая пробрасывает аргументы. Обёртка уже
|
|
||||||
называет предмет, поэтому короткая проектная команда здесь тоже выигрывает.
|
|
||||||
|
|
||||||
## Две задачи, которые нельзя смешивать
|
|
||||||
|
|
||||||
Они расходятся по частоте, по адресату и по тому, что считается провалом.
|
|
||||||
|
|
||||||
**Целостность набора.** Префиксы уникальны и не переиспользованы, шапка
|
|
||||||
совпадает с манифестом, тема объявлена и зарегистрирована, ось объявлена
|
|
||||||
ключами и у темы не больше одного базового слоя, у каждого правила
|
|
||||||
модальность с нормой и обоснование либо заглушка, нумерация сплошная, ссылки
|
|
||||||
разрешаются, путей набора в тексте конвенции нет, строка о версии языка на
|
|
||||||
месте. Запускается в наборе при каждой правке; провал — ошибка.
|
|
||||||
|
|
||||||
**Установка в проект.** Манифест подключения, сборка файла темы из слоёв на
|
|
||||||
каждый компонент, сохранение локальной части, `READING.md` рядом с копиями.
|
|
||||||
Запускается в проекте изредка; провал чаще означает «посмотри глазами», чем
|
|
||||||
«ошибка». Отчёта «набор ушёл вперёд» нет: его делает `git diff` после
|
|
||||||
пересборки.
|
|
||||||
|
|
||||||
## Что делает установка
|
|
||||||
|
|
||||||
Подробности — в README, раздел «Копия в репозитории». Коротко, что важно для
|
|
||||||
реализации:
|
|
||||||
|
|
||||||
- сборка идёт **по разу на компонент**, в директорию `dir` из его секции;
|
|
||||||
директории компонентов обязаны различаться — иначе копии столкнутся
|
|
||||||
именами, и это ошибка манифеста, а не повод переименовывать файлы;
|
|
||||||
- копия плоская, **один файл на тему**; слои идут секциями в порядке
|
|
||||||
база → язык → стек, выбор слоёв — по `lang` и `stack` компонента, сверяемым
|
|
||||||
с ключами оси в шапке слоя (META-38), а не с путём файла в наборе; слой без
|
|
||||||
ключей оси — базовый и попадает в копию всегда;
|
|
||||||
- набор может быть плоским: у темы один слой, ключей оси нет, `lang` и
|
|
||||||
`stack` в компоненте отсутствуют. Отдельной ветки в коде это не требует —
|
|
||||||
сборка из одного слоя есть копирование;
|
|
||||||
- шапка копии — только `origin:` с именем темы; отпечатков и дат нет;
|
|
||||||
- всё ниже маркера `<!-- conv:local -->` переживает пересборку, всё выше
|
|
||||||
перезаписывается; маркер ставит сборщик;
|
|
||||||
- `README.md` в директории принадлежит проекту и не трогается; `READING.md`
|
|
||||||
принадлежит набору и перезаписывается целиком;
|
|
||||||
- транспорта обратно нет: ни `push`, ни отчёта о расхождении, ни лок-файла.
|
|
||||||
|
|
||||||
## Что делает проверка
|
|
||||||
|
|
||||||
Список проверок — в `LANGUAGE.md`, раздел «Что стоит проверять машиной». Он
|
|
||||||
уже разделён на три части, и это деление прямо ложится в код:
|
|
||||||
|
|
||||||
- **форма правила** — разбором текста, в любом файле, который язык
|
|
||||||
употребляет: конвенции и `GUIDE.md`;
|
|
||||||
- **распространение** — разбором текста, только в файлах конвенций: тема в
|
|
||||||
шапке, префикс чужой темы в норме, пути набора, `X` у локальных правил;
|
|
||||||
- **чтением** — то, что машине не даётся: взаимоисключительность строк
|
|
||||||
таблицы, покрытие области действия, самодостаточность нормы, обоснование
|
|
||||||
отвечает на «что сломается», примеры не расширяют норму.
|
|
||||||
|
|
||||||
Третья часть — не работа `convy`. Её выполняет агент, и по ней стоит завести
|
|
||||||
скилл, а не пытаться выразить регуляркой.
|
|
||||||
|
|
||||||
## Независимость от набора
|
|
||||||
|
|
||||||
Инструмент не зашивает правила языка в код: версия и документы объявлены в
|
|
||||||
манифесте набора, секция `[language]`. Пока версия одна, это выглядит
|
|
||||||
избыточным — но именно здесь разница между «инструмент для этого канона» и
|
|
||||||
«инструмент для любого набора».
|
|
||||||
|
|
||||||
Отсюда же: никаких упоминаний конкретных тем, префиксов и путей в коде.
|
|
||||||
|
|
||||||
## Чего инструмент не делает
|
|
||||||
|
|
||||||
- не сливает трёхсторонне и не разрешает конфликты: правка выше маркера
|
|
||||||
теряется, и это заявленное поведение;
|
|
||||||
- не ведёт лок-файл: копии закоммичены, автоматического обновления нет,
|
|
||||||
ответ на «что было в прошлый раз» даёт git;
|
|
||||||
- не хранит список подписчиков: подписка — свойство проекта;
|
|
||||||
- не переносит правки из проекта в набор: это ручная и редкая операция.
|
|
||||||
|
|
||||||
## Открытые вопросы
|
|
||||||
|
|
||||||
- **Один бинарь или два.** Пока — один с подкомандой `suite`. Разделение
|
|
||||||
дешевле заложить сразу, чем отпиливать потом: если задачи разъедутся, у них
|
|
||||||
уже будут разные точки входа.
|
|
||||||
- **Установка.** `eget` умеет GitHub-релизы, а git у меня свой
|
|
||||||
(`git.vakhrushev.me`) — проверить, тянет ли `eget` релизы Gitea, иначе
|
|
||||||
остаётся прямой URL или `go install`.
|
|
||||||
- **TOML.** В stdlib парсера нет, значит одна внешняя зависимость
|
|
||||||
(`BurntSushi/toml` или `pelletier/go-toml`). Выбрать и больше зависимостей
|
|
||||||
не заводить.
|
|
||||||
- **Предупреждение о висячих ссылках** на неподписанные темы: это установка,
|
|
||||||
а не целостность, но список подписок ему нужен из манифеста подключения.
|
|
||||||
- **Проверка на `convey`** в текстах — вместе с остальными проверками формы
|
|
||||||
или отдельной мелочью.
|
|
||||||
- **Прототипы, на которые стоит посмотреть** до того, как писать: дистрибуция
|
|
||||||
пакетов Vale (`.vale.ini` → `vale sync` → `styles/`) как образец манифеста и
|
|
||||||
`vendir.yml` — как пример границы между «чего хочу» и «что получил».
|
|
||||||
|
|
||||||
## Состояние
|
|
||||||
|
|
||||||
`convy` не начат. В наборе лежит `conv` — питоновский скрипт под прежнюю
|
|
||||||
модель копий: зеркальное дерево, именованные регионы, `origin_hash`, команды
|
|
||||||
`status`, `diff`, `push`. Он не истина ни в чём: модель описана в README,
|
|
||||||
проверки — в `LANGUAGE.md`.
|
|
||||||
|
|
||||||
Логика проверок целостности написана и много раз прогнана руками, но живёт в
|
|
||||||
скретчпадах сессий, а не в репозитории. При старте разработки её стоит
|
|
||||||
перенести первой — это готовая спецификация в виде кода.
|
|
||||||
|
|
||||||
Переименование `conv` → `convy` в обвязке (`README.md`, `CLAUDE.md`) делается
|
|
||||||
одним заходом, когда инструмент будет готов.
|
|
||||||
@@ -1,516 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""conv — синхронизация конвенций между каноном и репозиторием.
|
|
||||||
|
|
||||||
Канон — директория conventions/ рядом с этим скриптом. Репозиторий держит
|
|
||||||
закоммиченные копии нужных конвенций в docs/conventions/, повторяя её
|
|
||||||
структуру. Копия — источник правды для репозитория; канон — лавка, из
|
|
||||||
которой берут. Пути в origin даются относительно conventions/.
|
|
||||||
|
|
||||||
Служебная разметка копии:
|
|
||||||
|
|
||||||
---
|
|
||||||
origin: arch/time.md # откуда взято
|
|
||||||
origin_hash: a1b2c3d4 # отпечаток канона на момент синхронизации
|
|
||||||
synced: 2026-07-25
|
|
||||||
local: нет # или текст: чем и почему разошлись
|
|
||||||
---
|
|
||||||
|
|
||||||
Прочие ключи шапки (status, extends) — часть документа: они сравниваются
|
|
||||||
наравне с телом и приезжают из канона.
|
|
||||||
|
|
||||||
Локальные регионы — куски, которые по определению принадлежат репозиторию
|
|
||||||
(механизация, отступления, «здесь решили так»). Из сравнения исключаются:
|
|
||||||
|
|
||||||
<!-- local:механизировано -->
|
|
||||||
...
|
|
||||||
<!-- /local -->
|
|
||||||
|
|
||||||
Имя региона обязательно: перенос при pull идёт по именам.
|
|
||||||
|
|
||||||
Команды:
|
|
||||||
conv list что есть в каноне
|
|
||||||
conv add arch/time.md [...] взять конвенцию в репозиторий
|
|
||||||
conv status состояние копий репозитория
|
|
||||||
conv diff [arch/time.md] чем копия отличается от канона
|
|
||||||
conv pull arch/time.md забрать обновление канона
|
|
||||||
conv push arch/time.md вернуть локальное улучшение в канон
|
|
||||||
conv push --new lang/go/x.md завести в каноне новую конвенцию
|
|
||||||
|
|
||||||
Везде можно указать --repo <path> (по умолчанию — текущая директория)
|
|
||||||
и --dir <subpath> (по умолчанию docs/conventions), до или после команды.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import datetime
|
|
||||||
import difflib
|
|
||||||
import hashlib
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import NoReturn
|
|
||||||
|
|
||||||
CANON = Path(__file__).resolve().parent / "conventions"
|
|
||||||
CANON_TREES = ("arch", "lang", "stack")
|
|
||||||
SERVICE_KEYS = ("origin", "origin_hash", "synced", "local")
|
|
||||||
DEFAULT_DIR = "docs/conventions"
|
|
||||||
ENC = "utf-8"
|
|
||||||
|
|
||||||
# Маркеры распознаются только в начале строки: так пример разметки внутри
|
|
||||||
# текста конвенции не превращается в настоящий регион.
|
|
||||||
REGION_RE = re.compile(
|
|
||||||
r"^<!--[ \t]*local(?::[ \t]*([^>]*?))?[ \t]*-->(.*?)^<!--[ \t]*/local[ \t]*-->",
|
|
||||||
re.DOTALL | re.MULTILINE,
|
|
||||||
)
|
|
||||||
OPEN_RE = re.compile(r"^<!--[ \t]*local", re.MULTILINE)
|
|
||||||
CLOSE_RE = re.compile(r"^<!--[ \t]*/local", re.MULTILINE)
|
|
||||||
|
|
||||||
|
|
||||||
def die(message: str) -> NoReturn:
|
|
||||||
print(f"conv: {message}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
# --- разметка --------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def read(path: Path) -> str:
|
|
||||||
return path.read_text(encoding=ENC)
|
|
||||||
|
|
||||||
|
|
||||||
def write(path: Path, text: str) -> None:
|
|
||||||
path.write_text(text, encoding=ENC)
|
|
||||||
|
|
||||||
|
|
||||||
def split_front(text: str) -> tuple[dict[str, str], str]:
|
|
||||||
"""Отделяет YAML-шапку (плоский key: value) от тела."""
|
|
||||||
if not text.startswith("---\n"):
|
|
||||||
return {}, text
|
|
||||||
end = text.find("\n---\n", 4)
|
|
||||||
if end == -1:
|
|
||||||
return {}, text
|
|
||||||
meta: dict[str, str] = {}
|
|
||||||
for line in text[4:end].splitlines():
|
|
||||||
if ":" in line:
|
|
||||||
key, value = line.split(":", 1)
|
|
||||||
meta[key.strip()] = value.strip()
|
|
||||||
return meta, text[end + 5 :]
|
|
||||||
|
|
||||||
|
|
||||||
def join_front(meta: dict[str, str], body: str) -> str:
|
|
||||||
if not meta:
|
|
||||||
return body
|
|
||||||
lines = "\n".join(f"{k}:{' ' + v if v else ''}" for k, v in meta.items())
|
|
||||||
return f"---\n{lines}\n---\n{body}"
|
|
||||||
|
|
||||||
|
|
||||||
def doc_keys(meta: dict[str, str]) -> dict[str, str]:
|
|
||||||
return {k: v for k, v in meta.items() if k not in SERVICE_KEYS}
|
|
||||||
|
|
||||||
|
|
||||||
def regions(body: str) -> dict[str, str]:
|
|
||||||
"""Содержимое локальных регионов по имени.
|
|
||||||
|
|
||||||
Поднимает ValueError на разметке, из-за которой регион молча превратился
|
|
||||||
бы в обычный текст и потерялся при pull.
|
|
||||||
"""
|
|
||||||
matched = len(REGION_RE.findall(body))
|
|
||||||
if len(OPEN_RE.findall(body)) != matched or len(CLOSE_RE.findall(body)) != matched:
|
|
||||||
raise ValueError("непарный или нераспознанный маркер локального региона")
|
|
||||||
found: dict[str, str] = {}
|
|
||||||
for match in REGION_RE.finditer(body):
|
|
||||||
name = (match.group(1) or "").strip()
|
|
||||||
content = match.group(2)
|
|
||||||
if not name:
|
|
||||||
if content.strip():
|
|
||||||
raise ValueError(
|
|
||||||
"безымянный локальный регион с содержимым — дай ему имя"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
if name in found:
|
|
||||||
raise ValueError(f"локальный регион '{name}' встречается дважды")
|
|
||||||
found[name] = content
|
|
||||||
return found
|
|
||||||
|
|
||||||
|
|
||||||
def checked_regions(body: str, where: str) -> dict[str, str]:
|
|
||||||
try:
|
|
||||||
return regions(body)
|
|
||||||
except ValueError as exc:
|
|
||||||
die(f"{where}: {exc}")
|
|
||||||
|
|
||||||
|
|
||||||
def blank_regions(body: str) -> str:
|
|
||||||
"""Тело с опустошёнными локальными регионами — то, что сравнивается."""
|
|
||||||
|
|
||||||
def repl(match: re.Match[str]) -> str:
|
|
||||||
raw = (match.group(1) or "").strip()
|
|
||||||
head = f"<!-- local:{raw} -->" if raw else "<!-- local -->"
|
|
||||||
return f"{head}\n<!-- /local -->"
|
|
||||||
|
|
||||||
return REGION_RE.sub(repl, body)
|
|
||||||
|
|
||||||
|
|
||||||
def fill_regions(body: str, values: dict[str, str]) -> tuple[str, list[str]]:
|
|
||||||
"""Вставляет содержимое регионов по имени. Возвращает тело и имена,
|
|
||||||
которым не нашлось места."""
|
|
||||||
used: set[str] = set()
|
|
||||||
|
|
||||||
def repl(match: re.Match[str]) -> str:
|
|
||||||
raw = (match.group(1) or "").strip()
|
|
||||||
head = f"<!-- local:{raw} -->" if raw else "<!-- local -->"
|
|
||||||
if raw in values:
|
|
||||||
used.add(raw)
|
|
||||||
return f"{head}{values[raw]}<!-- /local -->"
|
|
||||||
return match.group(0)
|
|
||||||
|
|
||||||
filled = REGION_RE.sub(repl, body)
|
|
||||||
lost = [n for n, v in values.items() if n not in used and v.strip()]
|
|
||||||
return filled, lost
|
|
||||||
|
|
||||||
|
|
||||||
def fingerprint(meta: dict[str, str], body: str) -> str:
|
|
||||||
"""Отпечаток документа: ключи шапки плюс тело без локальных регионов."""
|
|
||||||
head = "\n".join(f"{k}={v}" for k, v in sorted(doc_keys(meta).items()))
|
|
||||||
return hashlib.sha256(f"{head}\n\n{blank_regions(body)}".encode(ENC)).hexdigest()[
|
|
||||||
:8
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def today() -> str:
|
|
||||||
return datetime.date.today().isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
# --- канон и репозиторий ---------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def canon_list() -> list[str]:
|
|
||||||
out: list[str] = []
|
|
||||||
for tree in CANON_TREES:
|
|
||||||
root = CANON / tree
|
|
||||||
if root.is_dir():
|
|
||||||
out += [str(p.relative_to(CANON)) for p in sorted(root.rglob("*.md"))]
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def canon_read(origin: str) -> tuple[dict[str, str], str]:
|
|
||||||
path = CANON / origin
|
|
||||||
if not path.is_file():
|
|
||||||
die(f"в каноне нет {origin}")
|
|
||||||
return split_front(read(path))
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_origin(name: str, *, must_exist: bool = True) -> str:
|
|
||||||
"""Принимает 'arch/time.md', 'arch/time' и однозначный хвост вроде 'time'."""
|
|
||||||
name = name.strip("/")
|
|
||||||
if not name.endswith(".md"):
|
|
||||||
name += ".md"
|
|
||||||
candidate = (CANON / name).resolve()
|
|
||||||
if candidate.is_relative_to(CANON):
|
|
||||||
rel = str(candidate.relative_to(CANON))
|
|
||||||
if rel.split("/")[0] in CANON_TREES and (not must_exist or candidate.is_file()):
|
|
||||||
return rel
|
|
||||||
matches = [c for c in canon_list() if c == name or c.endswith("/" + name)]
|
|
||||||
if len(matches) == 1:
|
|
||||||
return matches[0]
|
|
||||||
if not matches:
|
|
||||||
die(f"в каноне нет {name} (путь должен начинаться с {'/'.join(CANON_TREES)})")
|
|
||||||
die(f"неоднозначно: {name} → {', '.join(matches)}")
|
|
||||||
|
|
||||||
|
|
||||||
def repo_dir(args: argparse.Namespace) -> Path:
|
|
||||||
return (Path(str(args.repo)) / str(args.dir)).resolve()
|
|
||||||
|
|
||||||
|
|
||||||
def repo_copies(base: Path) -> tuple[dict[str, Path], list[Path], list[str]]:
|
|
||||||
"""origin → копия; плюс .md без шапки и сообщения о нечитаемых файлах."""
|
|
||||||
found: dict[str, Path] = {}
|
|
||||||
untracked: list[Path] = []
|
|
||||||
problems: list[str] = []
|
|
||||||
if not base.is_dir():
|
|
||||||
return found, untracked, problems
|
|
||||||
for path in sorted(base.rglob("*.md")):
|
|
||||||
try:
|
|
||||||
meta, _ = split_front(read(path))
|
|
||||||
except (OSError, UnicodeDecodeError) as exc:
|
|
||||||
problems.append(f"{path.name}: не читается ({type(exc).__name__})")
|
|
||||||
continue
|
|
||||||
origin = meta.get("origin")
|
|
||||||
if not origin:
|
|
||||||
if path.name != "README.md":
|
|
||||||
untracked.append(path)
|
|
||||||
continue
|
|
||||||
if origin in found:
|
|
||||||
problems.append(
|
|
||||||
f"{origin}: две копии ({found[origin]}, {path}) — вторая скрыта"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
found[origin] = path
|
|
||||||
return found, untracked, problems
|
|
||||||
|
|
||||||
|
|
||||||
def locate(args: argparse.Namespace, origin: str) -> Path:
|
|
||||||
"""Путь копии: по шапке, если она лежит не по канонному пути."""
|
|
||||||
base = repo_dir(args)
|
|
||||||
copies, _, _ = repo_copies(base)
|
|
||||||
return copies.get(origin, base / origin)
|
|
||||||
|
|
||||||
|
|
||||||
# --- состояние -------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def state(
|
|
||||||
meta: dict[str, str], body: str, canon_meta: dict[str, str], canon_body: str
|
|
||||||
) -> str:
|
|
||||||
copy_fp = fingerprint(meta, body)
|
|
||||||
canon_fp = fingerprint(canon_meta, canon_body)
|
|
||||||
if copy_fp == canon_fp:
|
|
||||||
return "ok"
|
|
||||||
base = meta.get("origin_hash")
|
|
||||||
if not base:
|
|
||||||
return "нет origin_hash в шапке"
|
|
||||||
if base == canon_fp:
|
|
||||||
return "изменено локально"
|
|
||||||
if base == copy_fp:
|
|
||||||
return "канон обновился"
|
|
||||||
return "разошлись"
|
|
||||||
|
|
||||||
|
|
||||||
# --- команды ---------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_list(args: argparse.Namespace) -> int:
|
|
||||||
for origin in canon_list():
|
|
||||||
meta, _ = canon_read(origin)
|
|
||||||
marks = []
|
|
||||||
if "extends" in meta:
|
|
||||||
marks.append(f"расширяет {meta['extends']}")
|
|
||||||
if "status" in meta:
|
|
||||||
marks.append(meta["status"])
|
|
||||||
tail = f" ({'; '.join(marks)})" if marks else ""
|
|
||||||
print(f"{origin}{tail}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_add(args: argparse.Namespace) -> int:
|
|
||||||
base = repo_dir(args)
|
|
||||||
added = False
|
|
||||||
for raw in args.names:
|
|
||||||
origin = normalize_origin(raw)
|
|
||||||
target = base / origin
|
|
||||||
if target.exists():
|
|
||||||
print(f"{origin}: уже есть ({target}), пропускаю")
|
|
||||||
continue
|
|
||||||
canon_meta, canon_body = canon_read(origin)
|
|
||||||
checked_regions(canon_body, f"канон/{origin}")
|
|
||||||
meta: dict[str, str] = {
|
|
||||||
"origin": origin,
|
|
||||||
"origin_hash": fingerprint(canon_meta, canon_body),
|
|
||||||
"synced": today(),
|
|
||||||
"local": "нет",
|
|
||||||
}
|
|
||||||
meta.update(doc_keys(canon_meta))
|
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
write(target, join_front(meta, canon_body))
|
|
||||||
added = True
|
|
||||||
print(f"{origin} → {target}")
|
|
||||||
if "extends" in canon_meta:
|
|
||||||
print(f" расширяет {canon_meta['extends']} — возможно, нужна и она")
|
|
||||||
if added:
|
|
||||||
print("не забудь строку в docs/conventions/README.md")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_status(args: argparse.Namespace) -> int:
|
|
||||||
base = repo_dir(args)
|
|
||||||
copies, untracked, problems = repo_copies(base)
|
|
||||||
if not copies and not untracked and not problems:
|
|
||||||
print(f"в {base} нет копий конвенций")
|
|
||||||
return 0
|
|
||||||
width = max((len(o) for o in copies), default=0)
|
|
||||||
for origin, path in copies.items():
|
|
||||||
try:
|
|
||||||
meta, body = split_front(read(path))
|
|
||||||
except (OSError, UnicodeDecodeError) as exc:
|
|
||||||
print(f"{origin:<{width}} не читается ({type(exc).__name__})")
|
|
||||||
continue
|
|
||||||
if not (CANON / origin).is_file():
|
|
||||||
print(f"{origin:<{width}} нет в каноне")
|
|
||||||
continue
|
|
||||||
canon_meta, canon_body = canon_read(origin)
|
|
||||||
try:
|
|
||||||
regions(body)
|
|
||||||
except ValueError as exc:
|
|
||||||
print(f"{origin:<{width}} разметка: {exc}")
|
|
||||||
continue
|
|
||||||
local = meta.get("local", "нет")
|
|
||||||
note = "" if local == "нет" else f" [{local}]"
|
|
||||||
print(f"{origin:<{width}} {state(meta, body, canon_meta, canon_body)}{note}")
|
|
||||||
for path in untracked:
|
|
||||||
print(f"{path.name}: без шапки origin — не отслеживается")
|
|
||||||
for problem in problems:
|
|
||||||
print(problem)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_diff(args: argparse.Namespace) -> int:
|
|
||||||
base = repo_dir(args)
|
|
||||||
copies, _, _ = repo_copies(base)
|
|
||||||
targets = [normalize_origin(args.name)] if args.name else list(copies)
|
|
||||||
for origin in targets:
|
|
||||||
path = copies.get(origin)
|
|
||||||
if path is None:
|
|
||||||
print(f"{origin}: нет копии в репозитории")
|
|
||||||
continue
|
|
||||||
if not (CANON / origin).is_file():
|
|
||||||
print(f"{origin}: нет в каноне")
|
|
||||||
continue
|
|
||||||
meta, body = split_front(read(path))
|
|
||||||
canon_meta, canon_body = canon_read(origin)
|
|
||||||
if fingerprint(meta, body) == fingerprint(canon_meta, canon_body):
|
|
||||||
continue
|
|
||||||
sys.stdout.writelines(
|
|
||||||
difflib.unified_diff(
|
|
||||||
join_front(doc_keys(canon_meta), blank_regions(canon_body)).splitlines(
|
|
||||||
keepends=True
|
|
||||||
),
|
|
||||||
join_front(doc_keys(meta), blank_regions(body)).splitlines(
|
|
||||||
keepends=True
|
|
||||||
),
|
|
||||||
fromfile=f"канон/{origin}",
|
|
||||||
tofile=f"репо/{origin}",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_pull(args: argparse.Namespace) -> int:
|
|
||||||
origin = normalize_origin(args.name)
|
|
||||||
path = locate(args, origin)
|
|
||||||
if not path.is_file():
|
|
||||||
die(f"нет копии {origin} — сначала conv add {origin}")
|
|
||||||
meta, body = split_front(read(path))
|
|
||||||
canon_meta, canon_body = canon_read(origin)
|
|
||||||
checked_regions(canon_body, f"канон/{origin}")
|
|
||||||
local = checked_regions(body, f"репо/{origin}")
|
|
||||||
st = state(meta, body, canon_meta, canon_body)
|
|
||||||
if st == "ok":
|
|
||||||
fresh = fingerprint(canon_meta, canon_body)
|
|
||||||
if meta.get("origin_hash") != fresh:
|
|
||||||
meta["origin_hash"] = fresh
|
|
||||||
meta["synced"] = today()
|
|
||||||
write(path, join_front(meta, body))
|
|
||||||
print(f"{origin}: тексты совпадают, отпечаток освежён")
|
|
||||||
else:
|
|
||||||
print(f"{origin}: уже совпадает")
|
|
||||||
return 0
|
|
||||||
if st in ("изменено локально", "разошлись") and not args.force:
|
|
||||||
die(
|
|
||||||
f"{origin}: {st} — правки вне локальных регионов будут потеряны.\n"
|
|
||||||
f" посмотри conv diff {origin}, затем conv pull --force "
|
|
||||||
f"или conv push {origin}"
|
|
||||||
)
|
|
||||||
merged, lost = fill_regions(canon_body, local)
|
|
||||||
if lost and not args.force:
|
|
||||||
die(
|
|
||||||
f"{origin}: в каноне нет регионов {', '.join(lost)} — их содержимое "
|
|
||||||
f"пропадёт.\n перенеси вручную или conv pull --force"
|
|
||||||
)
|
|
||||||
for name in lost:
|
|
||||||
print(f" потерян локальный регион {name}")
|
|
||||||
new_meta = {k: meta[k] for k in SERVICE_KEYS if k in meta}
|
|
||||||
new_meta["origin_hash"] = fingerprint(canon_meta, canon_body)
|
|
||||||
new_meta["synced"] = today()
|
|
||||||
new_meta.update(doc_keys(canon_meta))
|
|
||||||
write(path, join_front(new_meta, merged))
|
|
||||||
print(f"{origin}: обновлено из канона — перечитай глазами, регионы могли устареть")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_push(args: argparse.Namespace) -> int:
|
|
||||||
origin = normalize_origin(args.name, must_exist=not args.new)
|
|
||||||
path = locate(args, origin)
|
|
||||||
if not path.is_file():
|
|
||||||
die(f"нет копии {origin}")
|
|
||||||
meta, body = split_front(read(path))
|
|
||||||
checked_regions(body, f"репо/{origin}")
|
|
||||||
target = CANON / origin
|
|
||||||
if not target.is_file():
|
|
||||||
if not args.new:
|
|
||||||
die(f"в каноне нет {origin} — заведи новую конвенцию через conv push --new")
|
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
write(target, join_front(doc_keys(meta), blank_regions(body)))
|
|
||||||
meta["origin_hash"] = fingerprint(doc_keys(meta), blank_regions(body))
|
|
||||||
meta["synced"] = today()
|
|
||||||
write(path, join_front(meta, body))
|
|
||||||
print(f"{origin}: заведена в каноне")
|
|
||||||
return 0
|
|
||||||
canon_meta, canon_body = canon_read(origin)
|
|
||||||
st = state(meta, body, canon_meta, canon_body)
|
|
||||||
if st == "ok":
|
|
||||||
print(f"{origin}: канон уже такой")
|
|
||||||
return 0
|
|
||||||
if st == "канон обновился":
|
|
||||||
die(
|
|
||||||
f"{origin}: копия не менялась, а канон ушёл вперёд — пушить нечего, нужен pull"
|
|
||||||
)
|
|
||||||
if st == "разошлись" and not args.force:
|
|
||||||
die(
|
|
||||||
f"{origin}: разошлись — канон менялся после синхронизации, "
|
|
||||||
f"его правки затрутся.\n посмотри conv diff {origin}, "
|
|
||||||
f"затем conv push --force"
|
|
||||||
)
|
|
||||||
write(target, join_front(doc_keys(meta), blank_regions(body)))
|
|
||||||
meta["origin_hash"] = fingerprint(doc_keys(meta), blank_regions(body))
|
|
||||||
meta["synced"] = today()
|
|
||||||
write(path, join_front(meta, body))
|
|
||||||
print(f"{origin}: канон обновлён из репозитория")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
common = argparse.ArgumentParser(add_help=False)
|
|
||||||
common.add_argument("--repo", default=".", help="корень репозитория")
|
|
||||||
common.add_argument("--dir", default=DEFAULT_DIR, help="где лежат конвенции")
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(prog="conv", parents=[common], description=__doc__)
|
|
||||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
||||||
|
|
||||||
sub.add_parser("list", parents=[common], help="что есть в каноне").set_defaults(
|
|
||||||
fn=cmd_list
|
|
||||||
)
|
|
||||||
|
|
||||||
p_add = sub.add_parser(
|
|
||||||
"add", parents=[common], help="взять конвенцию в репозиторий"
|
|
||||||
)
|
|
||||||
p_add.add_argument("names", nargs="+")
|
|
||||||
p_add.set_defaults(fn=cmd_add)
|
|
||||||
|
|
||||||
sub.add_parser("status", parents=[common], help="состояние копий").set_defaults(
|
|
||||||
fn=cmd_status
|
|
||||||
)
|
|
||||||
|
|
||||||
p_diff = sub.add_parser(
|
|
||||||
"diff", parents=[common], help="чем копия отличается от канона"
|
|
||||||
)
|
|
||||||
p_diff.add_argument("name", nargs="?")
|
|
||||||
p_diff.set_defaults(fn=cmd_diff)
|
|
||||||
|
|
||||||
p_pull = sub.add_parser("pull", parents=[common], help="забрать обновление канона")
|
|
||||||
p_pull.add_argument("name")
|
|
||||||
p_pull.add_argument("--force", action="store_true")
|
|
||||||
p_pull.set_defaults(fn=cmd_pull)
|
|
||||||
|
|
||||||
p_push = sub.add_parser("push", parents=[common], help="вернуть улучшение в канон")
|
|
||||||
p_push.add_argument("name")
|
|
||||||
p_push.add_argument("--force", action="store_true")
|
|
||||||
p_push.add_argument("--new", action="store_true", help="завести новый файл канона")
|
|
||||||
p_push.set_defaults(fn=cmd_push)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
return int(args.fn(args))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
Reference in New Issue
Block a user